diff --git a/.forgejo/workflows/nigig-build.yml b/.forgejo/workflows/nigig-build.yml index 75d5858..46fd5f7 100644 --- a/.forgejo/workflows/nigig-build.yml +++ b/.forgejo/workflows/nigig-build.yml @@ -22,6 +22,7 @@ on: - 'crates/nigig-uikit/**' - 'crates/matrix_client/**' - 'tools/test-cad-coverage.sh' + - 'tools/test-doc-workspace-coverage.sh' - 'Cargo.lock' - 'Cargo.toml' - 'rust-toolchain.toml' @@ -35,6 +36,7 @@ on: - 'crates/nigig-uikit/**' - 'crates/matrix_client/**' - 'tools/test-cad-coverage.sh' + - 'tools/test-doc-workspace-coverage.sh' - 'Cargo.lock' - 'Cargo.toml' - 'rust-toolchain.toml' @@ -510,3 +512,33 @@ jobs: - name: Engine coverage, with floors run: ./tools/test-cad-coverage.sh + + # --------------------------------------------------------------------- + # Source coverage for the document workspace's pure layer. + # + # Same shape as the CAD gate above: tools/test-doc-workspace-coverage.sh + # copies the doc module's dependency-free sources (model, layout, + # editing, collaboration, advanced JSON, CRDT bridge, projection layout/ + # session, mobile gestures, persistence seams) plus tests_pure.rs into a + # host-only crate with a makepad-math shim, runs them under + # -C instrument-coverage, and enforces a total floor plus a per-file + # floor for every instrumented file. Everything -- toolchain, cargo + # home, target dir, fetched Makepad tree, profraw data -- lives in a + # mktemp dir removed by a shell trap on every exit path. + # + # This does NOT cover the widget layer (mod.rs, crdt_widget.rs, + # widgets/, render/, projection_renderer.rs): those need live_design!, + # Cx and an event loop, and are gated by the full-crate build and test + # jobs above. persistence.rs keeps a lower floor on purpose: three + # write-path entry points save into the host's real application-data + # directory and are covered only through their path-injected seams + # (save_doc_state_to / load_saved_doc_state_with); see the doc module's + # COVERAGE.md for the honest exclusion list. + doc-workspace-coverage: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Doc workspace coverage, with floors + run: ./tools/test-doc-workspace-coverage.sh diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/COVERAGE.md b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/COVERAGE.md new file mode 100644 index 0000000..55d969d --- /dev/null +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/COVERAGE.md @@ -0,0 +1,99 @@ +# Doc-workspace source coverage + +`tools/test-doc-workspace-coverage.sh` measures line/region coverage for the +**pure** (widget-free) part of this module and enforces floors in CI +(`.forgejo/workflows/nigig-build.yml`, job `doc-workspace-coverage`). The same +suite also runs uninstrumented inside `cargo test -p nigig-build --lib`, so +every test executes twice: once in the real crate and once in the harness. + +## How it works + +The script copies the dependency-free sources — `advanced_json.rs`, +`crdt_bridge.rs`, `mobile_gesture.rs`, `persistence.rs`, +`projection_layout.rs`, `projection_session.rs`, and the `collaboration/`, +`editing/`, `layout/`, `model/`, `plugins/` trees — plus `tests_pure.rs` +byte-for-byte into a temporary host-only crate with the real module path. A 30 +line `makepad-widgets` shim (re-exporting `makepad_math` plus the `log!` / +`error!` macros) satisfies the only Makepad symbols the pure layer uses +(`DVec2`, `Rect`, `Vec4f`, `dvec2`, `vec4`). +`doc-engine` is included as a path dependency because the pure projection code +sits on top of it. + +The harness then installs its own pinned toolchain with +`llvm-tools-preview` into the same temp dir, runs the suite under +`-C instrument-coverage`, and enforces: + +* a **total** line floor (`DOC_WS_COVERAGE_TOTAL_FLOOR`), and +* a **per-file** floor for every instrumented source + (`DOC_WS_COVERAGE_PER_FILE_FLOORS`). + +The per-file floors are the point: deleting one file's whole test section +moves the total by a point or two and a lone number would wave that through. +Lowering a floor is a reviewable edit to the script, not something to do +quietly. Everything — toolchain, cargo home, target dir, fetched Makepad +tree, profraw data — is removed by a shell trap on every exit path; nothing +is written into the repository or `$HOME` unless `KEEP_COVERAGE=1` is set. + +## Running it + +```sh +./tools/test-doc-workspace-coverage.sh # gated run (CI) +DOC_WS_COVERAGE_REPORT_ONLY=1 ./tools/test-doc-workspace-coverage.sh # measure only +KEEP_COVERAGE=1 ./tools/test-doc-workspace-coverage.sh # keep uncovered-lines.txt + report +``` + +## Latest measurement (2026-08-17) + +| File | Lines covered | +| --- | --- | +| TOTAL | **96.76%** (6170 lines) | +| advanced_json.rs | 98.02% | +| crdt_bridge.rs | 95.45% | +| mobile_gesture.rs | 100% | +| persistence.rs | 65.85% (see exclusions) | +| projection_layout.rs | 98.52% | +| projection_session.rs | 100% | +| collaboration/* | 100% | +| editing/commands.rs | 93.39% | +| editing/controller.rs | 93.39% | +| editing/history.rs | 95.65% | +| layout/* | 94–100% | +| model/* | 91–100% | +| plugins/mod.rs | 100% | + +## What is intentionally not measured + +* **The widget layer** — `mod.rs`, `crdt_widget.rs`, `widgets/`, `render/`, + `projection_renderer.rs`, `tests.rs`. These need `live_design!`, a `Cx`, + and an event loop; they are gated by the full `nigig-build` build/test CI + jobs instead. The split is deliberate: `tests_pure.rs` contains everything + that can run host-only, and both files are compiled into the crate's normal + test suite. +* **`persistence.rs` write paths** — `save_doc_state` / `save_doc_state_as` / + `load_saved_doc_state` resolve the host's real application-data directory + and unconditionally write into it. They are thin wrappers over + `save_doc_state_to` / `load_saved_doc_state_with`, which the tests drive + with explicit temp paths; the wrappers themselves are covered by the + widget-runtime tests on device. This is why `persistence.rs` keeps a lower + floor (55%) — do not lower it further without a genuine new exclusion. +* **Defensive guards** — cycle guards in RGA/block-order traversal + (`seen.insert` `continue` arms), unreachable block-id parse failures inside + `layout_projection` (engine ids always contain `actor:counter`), and the + `unreachable` canvas-text serialization arm's sibling rejections in + `advanced_json.rs`. They exist for invariant defense; there is no honest + public-API path that reaches them. + +## Defects found by this coverage drive (fixed with tests) + +1. `editing/commands.rs::ReplaceBlockRange` validated the explicit + `block_ids` length **after** draining blocks out of the document, so a + malformed command destroyed content before reporting failure. Validation + now happens before any mutation; a test pins that a rejected replace + leaves blocks and ids untouched. +2. `model/crdt.rs::visit_children` was dead code (a String-collecting + duplicate of `visit_atoms` with no callers). Removed. +3. Multi-peer atom-id collisions: two `CrdtMetadata::default()` peers mint + identical `AtomId`s, so a concurrent insert silently resurrects instead + of inserting. The sync test now assigns distinct + `document.crdt.local_actor`s — matching how real peers must be + provisioned — and pins the convergent `"hi!"` exchange. diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md index 372384d..d5efdba 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/README.md @@ -1543,3 +1543,42 @@ end handle, drag onto 'w' in "world"): no request mid-drag, a fresh request on lift-off whose rect is wider than the stale one, focus landed on the dragged-to atom; the View-mode twin asserts the span adjusts but `clipboard_menu` stays empty. + +## Doc-workspace coverage gate (pure layer at 96.76% lines) + +The doc module had exactly the problem the CAD and doc-engine gates +were built for: a host-only-testable core that had never been measured +because `cargo test -p nigig-build` links wayland/X11/GL/alsa/polkit. +The test suite is now split in two — `tests_pure.rs` holds every test +that needs no `Cx` (model, layout, editing, collaboration, +advanced JSON, projection layout/session, CRDT bridge, persistence +seams, mobile gestures), and `tests.rs` keeps the widget-runtime and +boot tests. `tools/test-doc-workspace-coverage.sh` then copies the +pure sources plus `tests_pure.rs` into a temporary host-only crate +with a makepad-math shim (the same shape as the CAD gate), runs them +under `-C instrument-coverage`, and enforces a total floor plus a +per-file floor for every instrumented file. Baseline was **28.55%** +lines; the gate now holds **96.76%** with floors a few points under +per file (persistence keeps a documented lower floor — see +`COVERAGE.md` for the honest exclusion list and exact numbers). A new +`doc-workspace-coverage` CI job runs the script on every push that +touches the crate, and the script self-reports any pure file that +appears without a floor so the classification cannot silently rot. + +Growing the suite sat on the roadmap long enough that the exercise +also surfaced real behavior worth pinning, and two outright defects +that are now fixed: `Command::ReplaceBlockRange` used to validate its +explicit `block_ids` length after draining blocks out of the document +(a malformed remote command destroyed content before failing), and +`RgaText::visit_children` was a dead String-collecting duplicate of +`visit_atoms` (removed). The controller tests also nail two semantics +that were previously only folklore: remote typing between two +`DocumentController`s only converges when each peer owns a distinct +`document.crdt.local_actor` (the sync test assigns `alice`/`bob`), +and a mid-range `replace_range_crdt` renders its replacement after +the tombstoned subtree it replaced, because RGA siblings walk in id +order (`"hello" -> "hloY"` is pinned with the reasoning inline). The +baseline, the per-file floors, and what intentionally stays outside +the measurement (the whole widget layer, the persistence write-path +wrappers, the defensive traversal guards) live in `COVERAGE.md`; the +harness writes nothing outside a shell-trap-cleaned mktemp dir. diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/commands.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/commands.rs index 1211895..9b3c9ae 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/commands.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/editing/commands.rs @@ -445,6 +445,17 @@ impl Command { if index > document.blocks.len() || index + remove_count > document.blocks.len() { return None; } + let inserted = replacement.len(); + // Validate BEFORE mutating: a mismatched explicit id list must + // not drain blocks out of the document before this command + // reports failure. + let ids = match block_ids { + Some(ids) if ids.len() == inserted => ids, + Some(_) => return None, + None => (0..inserted) + .map(|_| BlockId(document.crdt.next_atom_id())) + .collect(), + }; document.ensure_legacy_block_ids(); let previous: Vec = document.blocks.drain(index..index + remove_count).collect(); @@ -455,15 +466,6 @@ impl Command { for id in &previous_ids { document.block_order.tombstone(id); } - let inserted = replacement.len(); - let ids = block_ids.unwrap_or_else(|| { - (0..inserted) - .map(|_| BlockId(document.crdt.next_atom_id())) - .collect() - }); - if ids.len() != inserted { - return None; - } let after = if index == 0 { None } else { diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/mod.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/mod.rs index 484d5ad..bd2cb6e 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/mod.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/mod.rs @@ -203,3 +203,5 @@ script_mod! { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_pure; diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/crdt.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/crdt.rs index 5be3b7c..aca79e0 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/crdt.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/model/crdt.rs @@ -115,29 +115,6 @@ impl RgaText { self.visit_atoms(Some(&atom.id), out, seen); } } - - fn visit_children( - &self, - after: Option<&AtomId>, - out: &mut String, - seen: &mut BTreeSet, - ) { - let mut children: Vec<&TextAtom> = self - .atoms - .values() - .filter(|atom| atom.after.as_ref() == after) - .collect(); - children.sort_by(|a, b| a.id.cmp(&b.id)); - for atom in children { - if !seen.insert(atom.id.clone()) { - continue; - } - if !atom.deleted { - out.push(atom.ch); - } - self.visit_children(Some(&atom.id), out, seen); - } - } } /// Tombstone compaction is deliberately explicit: callers must provide a diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs index 8e87a32..0ba2dd8 100644 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests.rs @@ -6,738 +6,6 @@ //! nothing and only broke the test build; it has been removed. The //! canonical implementation lives in `layout/layout_engine.rs`. -use super::collaboration::*; -use super::crdt_bridge::CrdtProjectionBridge; -use super::layout::*; -use super::model::*; - -#[test] -fn page_cache_invalidates_following_pages() { - let mut cache = PageLayoutCache::default(); - for index in 0..3 { - cache.put(LayoutPage { - index, - rect: makepad_widgets::Rect::default(), - content_rect: makepad_widgets::Rect::default(), - }); - } - cache.invalidate_from(1); - assert_eq!(cache.len(), 1); - assert!(cache.get(0).is_some()); - assert!(cache.get(1).is_none()); -} - -#[test] -#[ignore = "manual performance benchmark"] -fn benchmark_long_document_paragraph_layout() { - use std::time::Instant; - let spans = vec![StyleSpan { - text: "incremental layout benchmark text ".repeat(80), - font_size: 12.0, - ..Default::default() - }]; - let request = ParagraphLayoutRequest { - block_idx: 0, - spans: &spans, - origin: makepad_widgets::dvec2(0.0, 0.0), - available_width: 600.0, - align: DocAlign::Left, - default_font_size: 12.0, - line_spacing: 1.4, - }; - let start = Instant::now(); - for _ in 0..1_000 { - let _ = layout_paragraph(request.clone(), |_style, text| { - text.chars().count() as f64 * 6.0 - }); - } - eprintln!("paragraph layout benchmark: {:?}", start.elapsed()); -} - -#[test] -fn memory_transport_transfers_ack_messages() { - let mut transport = MemoryTransport::default(); - transport.send_ack(AckMessage { - actor: "peer".into(), - counter: 12, - }); - let wire: Vec<_> = transport.outgoing_acks.drain(..).collect(); - transport.incoming_acks.extend(wire); - let received = transport.receive_acks(); - assert_eq!(received[0].actor, "peer"); - assert_eq!(received[0].counter, 12); -} - -#[test] -fn acknowledgement_frontier_uses_slowest_peer() { - let mut session = CollaborationSession::new("local"); - session.acknowledge_frontier("a".into(), 10); - session.acknowledge_frontier("b".into(), 6); - session.acknowledge_frontier("a".into(), 12); - assert_eq!(session.safe_frontier(), Some(6)); -} - -#[test] -fn crdt_table_cells_are_addressed_by_stable_row_and_column_ids() { - let row = TableRowId(AtomId { - actor: "a".into(), - sequence: 1, - }); - let column = TableColumnId(AtomId { - actor: "a".into(), - sequence: 2, - }); - let cell = CrdtTableCell { - id: TableCellId(AtomId { - actor: "a".into(), - sequence: 3, - }), - row: row.clone(), - column: column.clone(), - row_span: 1, - col_span: 1, - children: Vec::new(), - deleted: false, - }; - let table = CrdtTable { - rows: vec![row.clone()], - columns: vec![column.clone()], - cells: vec![cell], - }; - assert!(table.cell(&row, &column).is_some()); -} - -#[test] -fn crdt_advanced_tree_filters_deleted_nodes() { - let id = AdvancedNodeId(AtomId { - actor: "a".into(), - sequence: 1, - }); - let node = DocumentNode { - id: 1, - style: BlockStyle::default(), - kind: BlockKind::Divider, - }; - let tree = CrdtAdvancedTree { - nodes: vec![CrdtAdvancedNode { - id: id.clone(), - parent: None, - order_after: None, - node, - deleted: false, - }], - }; - assert_eq!(tree.visible_children(None).len(), 1); -} - -#[test] -fn crdt_projection_bridge_materializes_text_and_style() { - let mut engine = doc_engine::controller::DocumentController::default(); - let block = engine.insert_block("a", None, "paragraph").unwrap(); - engine.insert_text("a", block.clone(), None, "bridge"); - engine.set_text_style_at_offsets( - "a", - block, - 0, - 6, - doc_engine::projection::TextStylePatch { - bold: Some(true), - ..Default::default() - }, - ); - let mut document = Document::default(); - CrdtProjectionBridge::apply(&engine.projection, &mut document); - match &document.blocks[0] { - DocBlock::Paragraph { spans, .. } => { - assert_eq!(spans[0].text, "bridge"); - assert!(spans[0].bold); - } - _ => panic!(), - } -} - -#[test] -fn crdt_projection_bridge_materializes_table() { - let mut engine = doc_engine::controller::DocumentController::default(); - let table = engine.insert_table("a", None).unwrap(); - let row = engine.insert_table_row("a", table.clone(), None).unwrap(); - let col = engine - .insert_table_column("a", table.clone(), None) - .unwrap(); - engine.set_table_cell("a", table, row, col, "cell"); - let mut document = Document::default(); - CrdtProjectionBridge::apply(&engine.projection, &mut document); - match &document.blocks[0] { - DocBlock::Table { cells, .. } => assert_eq!(cells[0][0].text, "cell"), - _ => panic!(), - } -} - -#[test] -fn crdt_projection_bridge_materializes_inline_advanced_node_ref() { - let mut engine = doc_engine::controller::DocumentController::default(); - engine.insert_node("a", None, None, "canvas", "{}"); - let mut document = Document::default(); - CrdtProjectionBridge::apply(&engine.projection, &mut document); - assert!(matches!( - document.blocks[0], - DocBlock::AdvancedNodeRef { .. } - )); - assert_eq!(document.nodes.len(), 1); -} - -#[test] -fn crdt_bridge_preserves_unified_block_node_order() { - let mut engine = doc_engine::controller::DocumentController::default(); - let block = engine.insert_block("a", None, "paragraph").unwrap(); - engine.insert_text("a", block.clone(), None, "before"); - engine.insert_node("a", None, Some(block), "canvas", "{}"); - let mut document = Document::default(); - CrdtProjectionBridge::apply(&engine.projection, &mut document); - assert!(matches!(document.blocks[0], DocBlock::Paragraph { .. })); - assert!(matches!( - document.blocks[1], - DocBlock::AdvancedNodeRef { .. } - )); -} - -#[test] -fn crdt_native_vertical_slice() { - let mut engine = doc_engine::controller::DocumentController::default(); - let block = engine.insert_block("test", None, "paragraph").unwrap(); - engine.insert_text("test", block.clone(), None, "hi"); - let layout = - crate::construction_frame::pages::workspace::doc::projection_layout::layout_projection( - &engine.projection, - ); - assert_eq!(engine.projection.blocks[0].text, "hi"); - let atom = layout.glyphs[0].atom.clone(); - engine.insert_text("test", block.clone(), Some(atom), "!"); - assert_eq!(engine.projection.blocks[0].text, "h!i"); - assert!(engine.undo("test")); - assert_eq!(engine.projection.blocks[0].text, "hi"); - let json = engine.document.to_json().unwrap(); - let restored = doc_engine::crdt::CrdtDocument::from_json(&json).unwrap(); - assert_eq!(restored.materialize().blocks[0].text, "hi"); -} - -use super::projection_layout::{ - block_glyph_offset, cell_char_offset_at, cell_text_line_col, cell_text_line_count, - cell_text_line_spans, cell_text_offset_at, cell_text_origin_y, cell_text_replace_range, - cell_text_span_rects, glyph_index_of, layout_projection, parse_op_id, projected_node_metrics, - projected_stats, selection_handles, step_glyph, word_atom_range, ProjectedTableLayout, - ADVANCED_NODE_BOTTOM_GAP, ADVANCED_NODE_WIDTH, LAYOUT_MARGIN, LINE_HEIGHT, - SELECTION_HANDLE_TOUCH_SLOP, TABLE_BOTTOM_GAP, TABLE_CELL_HEIGHT, TABLE_CELL_TEXT_INSET, - TABLE_CELL_TEXT_LINE_HEIGHT, TABLE_CELL_WIDTH, TEXT_CHAR_ADVANCE, -}; -use super::projection_session::{crdt_engine_from_saved, crdt_save_wire, CRDT_SAVE_HEADER}; -use doc_engine::controller::DocumentController as CrdtController; -use doc_engine::crdt::OpId; -use makepad_widgets::dvec2; - -/// Builds a 2x2 projected table with text in every cell and returns the -/// engine plus the stable table/row/column ids for further operations. -fn projection_table_engine() -> (CrdtController, OpId, OpId, OpId, OpId, OpId) { - let mut engine = CrdtController::default(); - let table = engine.insert_block("t", None, "table").unwrap(); - let row0 = engine.insert_table_row("t", table.clone(), None).unwrap(); - let row1 = engine - .insert_table_row("t", table.clone(), Some(row0.clone())) - .unwrap(); - let col0 = engine - .insert_table_column("t", table.clone(), None) - .unwrap(); - let col1 = engine - .insert_table_column("t", table.clone(), Some(col0.clone())) - .unwrap(); - engine.set_table_cell("t", table.clone(), row0.clone(), col0.clone(), "qty"); - engine.set_table_cell("t", table.clone(), row0.clone(), col1.clone(), "unit"); - engine.set_table_cell("t", table.clone(), row1.clone(), col0.clone(), "12"); - engine.set_table_cell("t", table.clone(), row1.clone(), col1.clone(), "bags"); - (engine, table, row0, row1, col0, col1) -} - -fn only_table(engine: &CrdtController) -> ProjectedTableLayout { - let tree = layout_projection(&engine.projection); - assert_eq!(tree.tables.len(), 1); - tree.tables.into_iter().next().unwrap() -} - -#[test] -fn projection_layout_places_table_cells_on_a_fixed_grid() { - let (engine, table_id, ..) = projection_table_engine(); - let table = only_table(&engine); - assert_eq!( - table.block_id, - format!("{}:{}", table_id.actor, table_id.counter) - ); - assert_eq!((table.rows, table.cols), (2, 2)); - assert_eq!(table.cells.len(), 4); - assert_eq!(table.rect.pos, dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN)); - assert_eq!( - table.rect.size, - dvec2(2.0 * TABLE_CELL_WIDTH, 2.0 * TABLE_CELL_HEIGHT) - ); - let top_left = table.cell(0, 0).unwrap(); - assert_eq!(top_left.text, "qty"); - assert_eq!(top_left.rect.pos, dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN)); - assert_eq!( - table.cell(0, 1).unwrap().rect.pos, - dvec2(LAYOUT_MARGIN + TABLE_CELL_WIDTH, LAYOUT_MARGIN) - ); - assert_eq!( - table.cell(1, 0).unwrap().rect.pos, - dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN + TABLE_CELL_HEIGHT) - ); - assert_eq!(table.cell(1, 1).unwrap().text, "bags"); - assert!(table.cells.iter().all(|cell| !cell.covered)); -} - -#[test] -fn projection_layout_merged_cell_spans_and_covers_its_range() { - let (mut engine, table, row0, _row1, col0, col1) = projection_table_engine(); - // Merges use stable row/column ids, never positional indexes. - engine.merge_table_cells("t", table, row0.clone(), col0, row0, col1); - let table = only_table(&engine); - let anchor = table.cell(0, 0).unwrap(); - assert_eq!((anchor.row_span, anchor.col_span), (1, 2)); - assert_eq!( - anchor.rect.size, - dvec2(2.0 * TABLE_CELL_WIDTH, TABLE_CELL_HEIGHT) - ); - assert_eq!(anchor.text, "qty"); - let covered = table.cell(0, 1).unwrap(); - assert!(covered.covered); - assert!(covered.text.is_empty()); - // A point inside the covered region resolves to the merge anchor. - let inside_covered = dvec2(LAYOUT_MARGIN + 1.5 * TABLE_CELL_WIDTH, LAYOUT_MARGIN + 4.0); - assert_eq!(table.hit(inside_covered), Some((0, 0))); -} - -#[test] -fn projection_layout_hit_test_maps_points_to_cell_coordinates() { - let (engine, ..) = projection_table_engine(); - let tree = layout_projection(&engine.projection); - let cell_11 = dvec2( - LAYOUT_MARGIN + 1.5 * TABLE_CELL_WIDTH, - LAYOUT_MARGIN + 1.5 * TABLE_CELL_HEIGHT, - ); - assert_eq!(tree.table_hit_test(cell_11), Some((0, 1, 1))); - let cell_00 = dvec2(LAYOUT_MARGIN + 1.0, LAYOUT_MARGIN + 1.0); - assert_eq!(tree.table_hit_test(cell_00), Some((0, 0, 0))); - // Below the table there is no cell. - let below = dvec2( - LAYOUT_MARGIN + 1.0, - LAYOUT_MARGIN + 2.0 * TABLE_CELL_HEIGHT + 40.0, - ); - assert_eq!(tree.table_hit_test(below), None); -} - -#[test] -fn projection_layout_advances_blocks_below_the_table() { - let (mut engine, ..) = projection_table_engine(); - let paragraph = engine.insert_block("t", None, "paragraph").unwrap(); - engine.insert_text("t", paragraph, None, "after"); - let tree = layout_projection(&engine.projection); - let table = &tree.tables[0]; - let table_bottom = table.rect.pos.y + table.rect.size.y; - // The paragraph is block 1; its origin must clear the table plus gap. - let paragraph_origin = tree.block_origins[1]; - assert_eq!(paragraph_origin.y, table_bottom + TABLE_BOTTOM_GAP); - // Its glyphs share the shifted line, not the legacy fixed line step. - let first_glyph = tree - .glyphs - .iter() - .find(|glyph| glyph.rect.pos.y > table_bottom) - .unwrap(); - assert_eq!(first_glyph.rect.pos.y, paragraph_origin.y); -} - -#[test] -fn projection_layout_interleaves_advanced_nodes_in_unified_order() { - let mut engine = CrdtController::default(); - let p1 = engine.insert_block("t", None, "paragraph").unwrap(); - engine.insert_text("t", p1.clone(), None, "before"); - // Anchoring the node after p1, and p2 after the node, interleaves them. - let canvas = engine - .insert_node("t", None, Some(p1.clone()), "canvas", "{}") - .unwrap(); - let p2 = engine - .insert_block("t", Some(canvas.clone()), "paragraph") - .unwrap(); - engine.insert_text("t", p2.clone(), None, "after"); - let tree = layout_projection(&engine.projection); - assert_eq!(tree.nodes.len(), 1); - let node = &tree.nodes[0]; - assert_eq!(node.kind, "canvas"); - assert_eq!(node.node_id, format!("{}:{}", canvas.actor, canvas.counter)); - // p1 holds the first line; the canvas placeholder sits right below it. - assert_eq!( - node.rect.pos, - dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN + LINE_HEIGHT) - ); - assert_eq!(node.rect.size, dvec2(ADVANCED_NODE_WIDTH, 240.0)); - assert_eq!(node.label, "Canvas"); - assert!(node.interactive); - // p2's origin clears the node plus its bottom gap. - let p2_index = engine - .projection - .blocks - .iter() - .position(|block| block.id == format!("{}:{}", p2.actor, p2.counter)) - .unwrap(); - assert_eq!( - tree.block_origins[p2_index], - dvec2( - LAYOUT_MARGIN, - node.rect.pos.y + node.rect.size.y + ADVANCED_NODE_BOTTOM_GAP - ) - ); - // The "after" glyphs sit on p2's shifted line, not the legacy fixed step. - let last_glyph = tree.glyphs.last().unwrap(); - assert_eq!(last_glyph.block, p2); - assert_eq!(last_glyph.rect.pos.y, tree.block_origins[p2_index].y); -} - -#[test] -fn projection_layout_node_metrics_match_legacy_advanced_layout() { - assert_eq!( - projected_node_metrics("image"), - (220.0, "Image".to_string(), true) - ); - assert_eq!( - projected_node_metrics("canvas"), - (240.0, "Canvas".to_string(), true) - ); - assert_eq!( - projected_node_metrics("divider"), - (18.0, "Divider".to_string(), false) - ); - assert_eq!( - projected_node_metrics("audio"), - (52.0, "Audio".to_string(), true) - ); - assert_eq!( - projected_node_metrics("video"), - (180.0, "Video".to_string(), true) - ); - assert_eq!( - projected_node_metrics("diagram"), - (160.0, "Diagram".to_string(), true) - ); - assert_eq!( - projected_node_metrics("quote"), - (56.0, "Quote".to_string(), false) - ); - assert_eq!( - projected_node_metrics("page_break"), - (36.0, "Page Break".to_string(), false) - ); - // Unknown kinds follow the bridge's EmbeddedWidget mapping. - assert_eq!( - projected_node_metrics("weather-pill"), - (64.0, "Widget: weather-pill".to_string(), true) - ); -} - -#[test] -fn projection_layout_node_hit_test_resolves_points_to_nodes() { - let mut engine = CrdtController::default(); - engine.insert_node("t", None, None, "image", "{}"); - let tree = layout_projection(&engine.projection); - let inside = dvec2(LAYOUT_MARGIN + 40.0, LAYOUT_MARGIN + 40.0); - assert_eq!(tree.node_hit_test(inside), Some(0)); - // Beyond the placeholder column there is no node. - let outside = dvec2( - LAYOUT_MARGIN + ADVANCED_NODE_WIDTH + 40.0, - LAYOUT_MARGIN + 1.0, - ); - assert_eq!(tree.node_hit_test(outside), None); -} - -#[test] -fn projection_layout_stacks_tables_nodes_and_text_in_one_column() { - let (mut engine, table, ..) = projection_table_engine(); - engine.insert_node("t", None, Some(table), "image", "{}"); - let tree = layout_projection(&engine.projection); - let table_layout = &tree.tables[0]; - let node = &tree.nodes[0]; - assert_eq!(node.kind, "image"); - // The image placeholder starts below the table plus its gap. - assert_eq!( - node.rect.pos.y, - table_layout.rect.pos.y + table_layout.rect.size.y + TABLE_BOTTOM_GAP - ); -} - -/// Engine with one paragraph reading "the quick brown fox": word starts -/// are at chars 0/4/10/16 for `word_atom_range` assertions. -fn projection_word_engine() -> CrdtController { - let mut engine = CrdtController::default(); - let block = engine.insert_block("t", None, "paragraph").unwrap(); - engine.insert_text("t", block, None, "the quick brown fox"); - engine -} - -#[test] -fn word_atom_range_selects_the_whitespace_delimited_word() { - let engine = projection_word_engine(); - let atoms = &engine.projection.blocks[0].runs[0].atoms; - // Any atom inside "quick" (chars 4..=8) selects the whole word. - for pivot in 4..=8 { - let (start, end) = word_atom_range(&engine.projection, &atoms[pivot]).unwrap(); - assert_eq!(start, atoms[4]); - assert_eq!(end, atoms[8]); - } -} - -#[test] -fn word_atom_range_picks_the_preceding_word_on_whitespace() { - let engine = projection_word_engine(); - let atoms = &engine.projection.blocks[0].runs[0].atoms; - // The space after "quick" resolves to "quick", matching legacy - // word_bounds pivot behaviour. - let (start, end) = word_atom_range(&engine.projection, &atoms[9]).unwrap(); - assert_eq!(start, atoms[4]); - assert_eq!(end, atoms[8]); -} - -#[test] -fn word_atom_range_at_line_end_selects_the_last_word() { - let engine = projection_word_engine(); - let atoms = &engine.projection.blocks[0].runs[0].atoms; - let last = atoms.len() - 1; - let (start, end) = word_atom_range(&engine.projection, &atoms[last]).unwrap(); - assert_eq!(start, atoms[16]); - assert_eq!(end, atoms[18]); -} - -#[test] -fn selection_handles_normalize_into_document_order() { - let mut engine = CrdtController::default(); - let block = engine.insert_block("t", None, "paragraph").unwrap(); - engine.insert_text("t", block, None, "abc"); - let layout = layout_projection(&engine.projection); - let glyph = |index: usize| &layout.glyphs[index]; - // A backwards drag (anchor on 'c', focus on 'a') still reports the - // start handle on 'a' and the end handle on 'c'. - let handles = selection_handles(&layout, &glyph(2).atom, &glyph(0).atom).unwrap(); - assert_eq!(handles.start_atom, glyph(0).atom); - assert_eq!(handles.end_atom, glyph(2).atom); - assert_eq!(handles.start_rect.pos.x, glyph(0).rect.pos.x - 8.0); - assert_eq!( - handles.end_rect.pos.x, - glyph(2).rect.pos.x + glyph(2).rect.size.x - 2.0 - ); - // A collapsed selection (anchor == focus) shows a caret, not handles. - assert!(selection_handles(&layout, &glyph(1).atom, &glyph(1).atom).is_none()); -} - -#[test] -fn selection_handle_hit_test_uses_the_touch_slop() { - let mut engine = CrdtController::default(); - let block = engine.insert_block("t", None, "paragraph").unwrap(); - engine.insert_text("t", block, None, "abc"); - let layout = layout_projection(&engine.projection); - let handles = - selection_handles(&layout, &layout.glyphs[0].atom, &layout.glyphs[2].atom).unwrap(); - assert_eq!( - handles.handle_at(handles.start_rect.pos + dvec2(1.0, 1.0)), - Some(true) - ); - assert_eq!( - handles.handle_at(handles.end_rect.pos + dvec2(1.0, 1.0)), - Some(false) - ); - // Just outside the rect is still a hit, within the slop. - let slop = SELECTION_HANDLE_TOUCH_SLOP - 2.0; - let near = dvec2( - handles.start_rect.pos.x - slop, - handles.start_rect.pos.y - slop, - ); - assert_eq!(handles.handle_at(near), Some(true)); - // Well past the slop there is no handle. - let far = dvec2( - handles.start_rect.pos.x - 40.0, - handles.start_rect.pos.y - 40.0, - ); - assert_eq!(handles.handle_at(far), None); -} - -/// Two-block engine ("ab" / "cd") for glyph-stream stepping assertions. -fn projection_two_block_engine() -> CrdtController { - let mut engine = CrdtController::default(); - let first = engine.insert_block("t", None, "paragraph").unwrap(); - engine.insert_text("t", first.clone(), None, "ab"); - let second = engine.insert_block("t", Some(first), "paragraph").unwrap(); - engine.insert_text("t", second, None, "cd"); - engine -} - -#[test] -fn step_glyph_crosses_block_boundaries_in_unified_order() { - let engine = projection_two_block_engine(); - let layout = layout_projection(&engine.projection); - // Stream is [a, b, c, d], blocks [ab] [cd]: stepping past 'b' lands on - // 'c' in the next block without block-aware special cases. - let b_atom = layout.glyphs[1].atom.clone(); - let c_glyph = step_glyph(&layout, &b_atom, true).unwrap(); - assert_eq!(c_glyph.atom, layout.glyphs[2].atom); - assert_ne!(c_glyph.block, layout.glyphs[1].block); - let back = step_glyph(&layout, &c_glyph.atom.clone(), false).unwrap(); - assert_eq!(back.atom, b_atom); - // Document edges yield no glyph. - assert!(step_glyph(&layout, &layout.glyphs[0].atom, false).is_none()); - assert!(step_glyph(&layout, &layout.glyphs[3].atom, true).is_none()); -} - -#[test] -fn block_glyph_offset_positions_atoms_inside_their_block() { - let engine = projection_two_block_engine(); - let layout = layout_projection(&engine.projection); - let first_block = layout.glyphs[0].block.clone(); - assert_eq!( - block_glyph_offset(&engine.projection, &first_block, &layout.glyphs[0].atom), - Some(0) - ); - assert_eq!( - block_glyph_offset(&engine.projection, &first_block, &layout.glyphs[1].atom), - Some(1) - ); - // 'c' lives in the second block: querying it against the first fails. - let second_block = layout.glyphs[2].block.clone(); - assert_eq!( - block_glyph_offset(&engine.projection, &first_block, &layout.glyphs[2].atom), - None - ); - assert_eq!( - block_glyph_offset(&engine.projection, &second_block, &layout.glyphs[2].atom), - Some(0) - ); - assert_eq!(glyph_index_of(&layout, &layout.glyphs[2].atom), Some(2)); -} - -#[test] -fn parse_op_id_round_trips_and_rejects_garbage() { - let id = OpId { - actor: "peer-7".to_string(), - counter: 42, - }; - let parsed = parse_op_id(&format!("{}:{}", id.actor, id.counter)).unwrap(); - assert_eq!(parsed, id); - assert!(parse_op_id("no-counter").is_none()); - assert!(parse_op_id("a:notanumber").is_none()); - assert!(parse_op_id("").is_none()); -} - -/// Engine with styled text, an aligned paragraph, a populated table and an -/// image node — the full surface the workspace save button must preserve. -fn crdt_save_wire_engine() -> (CrdtController, OpId) { - let mut engine = CrdtController::default(); - let block = engine.insert_block("t", None, "paragraph").unwrap(); - engine.insert_text("t", block.clone(), None, "hello crdt world"); - engine.toggle_text_style_at_offsets("t", block.clone(), 0, 5, "bold"); - engine.set_block_alignment("t", block.clone(), "Center"); - let table = engine.insert_table("t", Some(block.clone())).unwrap(); - let row = engine.insert_table_row("t", table.clone(), None).unwrap(); - let col = engine - .insert_table_column("t", table.clone(), None) - .unwrap(); - engine.set_table_cell("t", table.clone(), row, col, "two words"); - engine - .insert_node("t", None, Some(table), "image", "caption") - .unwrap(); - (engine, block) -} - -#[test] -fn crdt_save_wire_round_trips_engine_state() { - let (engine, block) = crdt_save_wire_engine(); - let wire = crdt_save_wire(&engine.document).expect("wire serializes"); - assert!(wire.starts_with(CRDT_SAVE_HEADER)); - let restored = crdt_engine_from_saved(&wire).expect("wire parses"); - // Same block sequence, text and alignment. - let ids: Vec = engine - .projection - .blocks - .iter() - .map(|b| b.text.clone()) - .collect(); - let restored_ids: Vec = restored - .projection - .blocks - .iter() - .map(|b| b.text.clone()) - .collect(); - assert_eq!(restored_ids, ids); - let id = format!("{}:{}", block.actor, block.counter); - let aligned = restored - .projection - .blocks - .iter() - .find(|b| b.id == id) - .unwrap(); - assert_eq!(aligned.alignment, "Center"); - // Styled runs survive the wire. - let original_runs = engine.projection.blocks[0].runs.len(); - assert_eq!(aligned.runs.len(), original_runs); - assert!(aligned.runs.iter().any(|run| run.bold)); - // Table cells and the advanced node survive too. - assert_eq!(restored.projection.tables.len(), 1); - let table = restored.projection.tables.values().next().unwrap(); - assert!(table.cells.values().any(|cell| cell == "two words")); - assert_eq!(restored.projection.nodes.len(), 1); - assert_eq!(restored.projection.nodes[0].kind, "image"); - assert_eq!(restored.projection.order, engine.projection.order); -} - -#[test] -fn crdt_engine_from_saved_rejects_legacy_delimiter_and_garbage() { - // Legacy delimiter documents stay with the classic editor's migration. - assert!( - crdt_engine_from_saved("M|local|1|1\nP|Left|hello§false§false§false§12.0§~\n").is_none() - ); - assert!(crdt_engine_from_saved("#MP_CRDT_V1\n{not json").is_none()); - assert!(crdt_engine_from_saved("").is_none()); - assert!(crdt_engine_from_saved("#MP_CRDT_V1\n").is_none()); -} - -#[test] -fn projected_stats_counts_words_and_chars_in_blocks_and_tables() { - let (engine, _) = crdt_save_wire_engine(); - let (words, chars) = projected_stats(&engine.projection); - // "hello crdt world" (3 words, 16 chars) + table cell "two words" - // (2 words, 9 chars); the image node contributes nothing. - assert_eq!(words, 5); - assert_eq!(chars, 25); - let empty = CrdtController::default(); - assert_eq!(projected_stats(&empty.projection), (0, 0)); -} - -#[test] -fn projection_layout_falls_back_to_block_order_when_order_is_empty() { - // A projection assembled without the operation log has no unified order. - let mut projection = doc_engine::projection::DocumentProjection::default(); - for index in 0..2 { - projection - .blocks - .push(doc_engine::projection::ProjectedBlock { - id: format!("a:{}", index + 1), - kind: "paragraph".to_string(), - text: "x".to_string(), - ..Default::default() - }); - } - let tree = layout_projection(&projection); - assert_eq!(tree.block_origins[0], dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN)); - assert_eq!( - tree.block_origins[1], - dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN + LINE_HEIGHT) - ); - assert!(tree.nodes.is_empty()); - assert!(tree.tables.is_empty()); -} - // == CrdtDocEditor runtime integration ===================================== // These tests run the real widget against a real `Cx` runtime and real // `Event` values: the editor instance is built through the same @@ -758,6 +26,20 @@ fn projection_layout_falls_back_to_block_order_when_order_is_empty() { use super::CrdtDocEditor; use makepad_widgets::makepad_platform::makepad_script::{ScriptNew, ScriptVm, ScriptVmBase}; use makepad_widgets::{Cx, Event, KeyCode, KeyEvent, KeyModifiers, Scope, Widget}; +// Shared with the pure-logic suite in tests_pure.rs: the projection +// helpers above the runtime harness section live there single-source. +use super::projection_layout::{ + block_glyph_offset, cell_char_offset_at, cell_text_line_col, cell_text_line_count, + cell_text_line_spans, cell_text_offset_at, cell_text_origin_y, cell_text_replace_range, + cell_text_span_rects, layout_projection, parse_op_id, selection_handles, TABLE_BOTTOM_GAP, + TABLE_CELL_HEIGHT, TABLE_CELL_TEXT_INSET, TABLE_CELL_TEXT_LINE_HEIGHT, TABLE_CELL_WIDTH, + TEXT_CHAR_ADVANCE, +}; +use super::projection_session::crdt_save_wire; +use super::tests_pure::{only_table, projection_table_engine}; +use doc_engine::controller::DocumentController as CrdtController; +use doc_engine::crdt::OpId; +use makepad_widgets::dvec2; fn runtime_key_down(code: KeyCode) -> Event { Event::KeyDown(KeyEvent { @@ -5374,7 +4656,6 @@ fn layout_tree_cache_drops_on_engine_replacement() { // precedence of the migrated persistence layer. use super::crdt_widget::initial_document_source; -use super::persistence::{load_saved_doc_state_with, save_doc_state_to, GENERATED_DOC_FILE}; /// A factory-fresh editor with NO engine installed: the exact boot state /// of the APK that opened blank. @@ -5515,79 +4796,3 @@ fn seed_demo_doc_builds_showcase_document() { "table header row renders bold" ); } - -// ---- persistence store migration -------------------------------------- - -fn temp_store_dir(tag: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!( - "nigig-doc-persistence-{}-{tag}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).expect("temp dir"); - dir -} - -#[test] -fn saved_doc_round_trips_through_runtime_store() { - let store = temp_store_dir("round-trip"); - save_doc_state_to(store.clone(), GENERATED_DOC_FILE, "{\"doc\":\"state\"}").expect("save"); - assert_eq!( - load_saved_doc_state_with(store.clone(), store.join("no-manifest-here")), - Some("{\"doc\":\"state\"}".to_string()) - ); - let _ = std::fs::remove_dir_all(&store); -} - -#[test] -fn runtime_store_takes_precedence_over_manifest_fallback() { - let store = temp_store_dir("store"); - let manifest = temp_store_dir("manifest"); - save_doc_state_to(store.clone(), GENERATED_DOC_FILE, "store version").expect("store save"); - save_doc_state_to(manifest.clone(), GENERATED_DOC_FILE, "manifest version") - .expect("manifest save"); - assert_eq!( - load_saved_doc_state_with(store.clone(), manifest.clone()), - Some("store version".to_string()), - "a document in the app-data store wins over the legacy source-tree copy" - ); - let _ = std::fs::remove_dir_all(&store); - let _ = std::fs::remove_dir_all(&manifest); -} - -#[test] -fn manifest_fallback_serves_legacy_saves_once() { - let store = temp_store_dir("empty-store"); - let manifest = temp_store_dir("legacy-manifest"); - // No file in the runtime store: the legacy source-tree copy is still - // honored (it is read-only now — new saves never go there). - save_doc_state_to(manifest.clone(), GENERATED_DOC_FILE, "legacy save").expect("manifest save"); - assert_eq!( - load_saved_doc_state_with(store.clone(), manifest.clone()), - Some("legacy save".to_string()) - ); - let _ = std::fs::remove_dir_all(&store); - let _ = std::fs::remove_dir_all(&manifest); -} - -#[test] -fn empty_or_missing_save_files_boot_fresh() { - let store = temp_store_dir("empty-file-store"); - let manifest = temp_store_dir("missing-manifest"); - save_doc_state_to(store.clone(), GENERATED_DOC_FILE, " \n").expect("save"); - // An empty/whitespace save is treated as absent: the workspace boots - // fresh (demo seed) rather than feeding the loader a blank document. - assert_eq!( - load_saved_doc_state_with(store.clone(), manifest.clone()), - None - ); - // And with nothing anywhere there is nothing to load. - let bare = temp_store_dir("bare"); - assert_eq!( - load_saved_doc_state_with(bare.clone(), bare.join("none")), - None - ); - let _ = std::fs::remove_dir_all(&store); - let _ = std::fs::remove_dir_all(&manifest); - let _ = std::fs::remove_dir_all(&bare); -} diff --git a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests_pure.rs b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests_pure.rs new file mode 100644 index 0000000..903325a --- /dev/null +++ b/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests_pure.rs @@ -0,0 +1,4251 @@ +//! Host-only pure-logic tests for the `doc` module. +//! +//! Every test in this file exercises only dependency-free logic (model, +//! layout, collaboration, projection layout/session, persistence), so +//! the same sources run twice: once inside the crate's lib suite and +//! once inside tools/test-doc-workspace-coverage.sh's instrumented +//! host-only harness, which copies this file byte-for-byte as its test +//! module. Anything that needs a `Cx`, the `Widget` trait, or a script +//! VM belongs in `tests.rs`, not here. +//! +//! (The original `// == CrdtDocEditor runtime integration` section and +//! everything after it stayed in `tests.rs`; the widget-runtime history +//! notes live there.) +use super::collaboration::*; +use super::crdt_bridge::CrdtProjectionBridge; +use super::layout::*; +use super::model::*; +use super::persistence::{load_saved_doc_state_with, save_doc_state_to, GENERATED_DOC_FILE}; + +#[test] +fn page_cache_invalidates_following_pages() { + let mut cache = PageLayoutCache::default(); + for index in 0..3 { + cache.put(LayoutPage { + index, + rect: makepad_widgets::Rect::default(), + content_rect: makepad_widgets::Rect::default(), + }); + } + cache.invalidate_from(1); + assert_eq!(cache.len(), 1); + assert!(cache.get(0).is_some()); + assert!(cache.get(1).is_none()); +} + +#[test] +#[ignore = "manual performance benchmark"] +fn benchmark_long_document_paragraph_layout() { + use std::time::Instant; + let spans = vec![StyleSpan { + text: "incremental layout benchmark text ".repeat(80), + font_size: 12.0, + ..Default::default() + }]; + let request = ParagraphLayoutRequest { + block_idx: 0, + spans: &spans, + origin: makepad_widgets::dvec2(0.0, 0.0), + available_width: 600.0, + align: DocAlign::Left, + default_font_size: 12.0, + line_spacing: 1.4, + }; + let start = Instant::now(); + for _ in 0..1_000 { + let _ = layout_paragraph(request.clone(), |_style, text| { + text.chars().count() as f64 * 6.0 + }); + } + eprintln!("paragraph layout benchmark: {:?}", start.elapsed()); +} + +#[test] +fn memory_transport_transfers_ack_messages() { + let mut transport = MemoryTransport::default(); + transport.send_ack(AckMessage { + actor: "peer".into(), + counter: 12, + }); + let wire: Vec<_> = transport.outgoing_acks.drain(..).collect(); + transport.incoming_acks.extend(wire); + let received = transport.receive_acks(); + assert_eq!(received[0].actor, "peer"); + assert_eq!(received[0].counter, 12); +} + +#[test] +fn acknowledgement_frontier_uses_slowest_peer() { + let mut session = CollaborationSession::new("local"); + session.acknowledge_frontier("a".into(), 10); + session.acknowledge_frontier("b".into(), 6); + session.acknowledge_frontier("a".into(), 12); + assert_eq!(session.safe_frontier(), Some(6)); +} + +#[test] +fn crdt_table_cells_are_addressed_by_stable_row_and_column_ids() { + let row = TableRowId(AtomId { + actor: "a".into(), + sequence: 1, + }); + let column = TableColumnId(AtomId { + actor: "a".into(), + sequence: 2, + }); + let cell = CrdtTableCell { + id: TableCellId(AtomId { + actor: "a".into(), + sequence: 3, + }), + row: row.clone(), + column: column.clone(), + row_span: 1, + col_span: 1, + children: Vec::new(), + deleted: false, + }; + let table = CrdtTable { + rows: vec![row.clone()], + columns: vec![column.clone()], + cells: vec![cell], + }; + assert!(table.cell(&row, &column).is_some()); +} + +#[test] +fn crdt_advanced_tree_filters_deleted_nodes() { + let id = AdvancedNodeId(AtomId { + actor: "a".into(), + sequence: 1, + }); + let node = DocumentNode { + id: 1, + style: BlockStyle::default(), + kind: BlockKind::Divider, + }; + let tree = CrdtAdvancedTree { + nodes: vec![CrdtAdvancedNode { + id: id.clone(), + parent: None, + order_after: None, + node, + deleted: false, + }], + }; + assert_eq!(tree.visible_children(None).len(), 1); +} + +#[test] +fn crdt_projection_bridge_materializes_text_and_style() { + let mut engine = doc_engine::controller::DocumentController::default(); + let block = engine.insert_block("a", None, "paragraph").unwrap(); + engine.insert_text("a", block.clone(), None, "bridge"); + engine.set_text_style_at_offsets( + "a", + block, + 0, + 6, + doc_engine::projection::TextStylePatch { + bold: Some(true), + ..Default::default() + }, + ); + let mut document = Document::default(); + CrdtProjectionBridge::apply(&engine.projection, &mut document); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => { + assert_eq!(spans[0].text, "bridge"); + assert!(spans[0].bold); + } + _ => panic!(), + } +} + +#[test] +fn crdt_projection_bridge_materializes_table() { + let mut engine = doc_engine::controller::DocumentController::default(); + let table = engine.insert_table("a", None).unwrap(); + let row = engine.insert_table_row("a", table.clone(), None).unwrap(); + let col = engine + .insert_table_column("a", table.clone(), None) + .unwrap(); + engine.set_table_cell("a", table, row, col, "cell"); + let mut document = Document::default(); + CrdtProjectionBridge::apply(&engine.projection, &mut document); + match &document.blocks[0] { + DocBlock::Table { cells, .. } => assert_eq!(cells[0][0].text, "cell"), + _ => panic!(), + } +} + +#[test] +fn crdt_projection_bridge_materializes_inline_advanced_node_ref() { + let mut engine = doc_engine::controller::DocumentController::default(); + engine.insert_node("a", None, None, "canvas", "{}"); + let mut document = Document::default(); + CrdtProjectionBridge::apply(&engine.projection, &mut document); + assert!(matches!( + document.blocks[0], + DocBlock::AdvancedNodeRef { .. } + )); + assert_eq!(document.nodes.len(), 1); +} + +#[test] +fn crdt_bridge_preserves_unified_block_node_order() { + let mut engine = doc_engine::controller::DocumentController::default(); + let block = engine.insert_block("a", None, "paragraph").unwrap(); + engine.insert_text("a", block.clone(), None, "before"); + engine.insert_node("a", None, Some(block), "canvas", "{}"); + let mut document = Document::default(); + CrdtProjectionBridge::apply(&engine.projection, &mut document); + assert!(matches!(document.blocks[0], DocBlock::Paragraph { .. })); + assert!(matches!( + document.blocks[1], + DocBlock::AdvancedNodeRef { .. } + )); +} + +#[test] +fn crdt_native_vertical_slice() { + let mut engine = doc_engine::controller::DocumentController::default(); + let block = engine.insert_block("test", None, "paragraph").unwrap(); + engine.insert_text("test", block.clone(), None, "hi"); + let layout = + crate::construction_frame::pages::workspace::doc::projection_layout::layout_projection( + &engine.projection, + ); + assert_eq!(engine.projection.blocks[0].text, "hi"); + let atom = layout.glyphs[0].atom.clone(); + engine.insert_text("test", block.clone(), Some(atom), "!"); + assert_eq!(engine.projection.blocks[0].text, "h!i"); + assert!(engine.undo("test")); + assert_eq!(engine.projection.blocks[0].text, "hi"); + let json = engine.document.to_json().unwrap(); + let restored = doc_engine::crdt::CrdtDocument::from_json(&json).unwrap(); + assert_eq!(restored.materialize().blocks[0].text, "hi"); +} + +use super::projection_layout::{ + block_glyph_offset, cell_char_offset_at, cell_text_line_col, cell_text_line_count, + cell_text_line_spans, cell_text_offset_at, cell_text_origin_y, cell_text_replace_range, + cell_text_span_rects, glyph_index_of, layout_projection, parse_op_id, projected_node_metrics, + projected_stats, selection_handles, step_glyph, word_atom_range, ProjectedTableLayout, + ADVANCED_NODE_BOTTOM_GAP, ADVANCED_NODE_WIDTH, LAYOUT_MARGIN, LINE_HEIGHT, + SELECTION_HANDLE_TOUCH_SLOP, TABLE_BOTTOM_GAP, TABLE_CELL_HEIGHT, TABLE_CELL_TEXT_INSET, + TABLE_CELL_TEXT_LINE_HEIGHT, TABLE_CELL_WIDTH, TEXT_CHAR_ADVANCE, +}; +use super::projection_session::{crdt_engine_from_saved, crdt_save_wire, CRDT_SAVE_HEADER}; +use doc_engine::controller::DocumentController as CrdtController; +use doc_engine::crdt::OpId; +use makepad_widgets::dvec2; + +/// Builds a 2x2 projected table with text in every cell and returns the +/// engine plus the stable table/row/column ids for further operations. +pub(super) fn projection_table_engine() -> (CrdtController, OpId, OpId, OpId, OpId, OpId) { + let mut engine = CrdtController::default(); + let table = engine.insert_block("t", None, "table").unwrap(); + let row0 = engine.insert_table_row("t", table.clone(), None).unwrap(); + let row1 = engine + .insert_table_row("t", table.clone(), Some(row0.clone())) + .unwrap(); + let col0 = engine + .insert_table_column("t", table.clone(), None) + .unwrap(); + let col1 = engine + .insert_table_column("t", table.clone(), Some(col0.clone())) + .unwrap(); + engine.set_table_cell("t", table.clone(), row0.clone(), col0.clone(), "qty"); + engine.set_table_cell("t", table.clone(), row0.clone(), col1.clone(), "unit"); + engine.set_table_cell("t", table.clone(), row1.clone(), col0.clone(), "12"); + engine.set_table_cell("t", table.clone(), row1.clone(), col1.clone(), "bags"); + (engine, table, row0, row1, col0, col1) +} + +pub(super) fn only_table(engine: &CrdtController) -> ProjectedTableLayout { + let tree = layout_projection(&engine.projection); + assert_eq!(tree.tables.len(), 1); + tree.tables.into_iter().next().unwrap() +} + +#[test] +fn projection_layout_places_table_cells_on_a_fixed_grid() { + let (engine, table_id, ..) = projection_table_engine(); + let table = only_table(&engine); + assert_eq!( + table.block_id, + format!("{}:{}", table_id.actor, table_id.counter) + ); + assert_eq!((table.rows, table.cols), (2, 2)); + assert_eq!(table.cells.len(), 4); + assert_eq!(table.rect.pos, dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN)); + assert_eq!( + table.rect.size, + dvec2(2.0 * TABLE_CELL_WIDTH, 2.0 * TABLE_CELL_HEIGHT) + ); + let top_left = table.cell(0, 0).unwrap(); + assert_eq!(top_left.text, "qty"); + assert_eq!(top_left.rect.pos, dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN)); + assert_eq!( + table.cell(0, 1).unwrap().rect.pos, + dvec2(LAYOUT_MARGIN + TABLE_CELL_WIDTH, LAYOUT_MARGIN) + ); + assert_eq!( + table.cell(1, 0).unwrap().rect.pos, + dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN + TABLE_CELL_HEIGHT) + ); + assert_eq!(table.cell(1, 1).unwrap().text, "bags"); + assert!(table.cells.iter().all(|cell| !cell.covered)); +} + +#[test] +fn projection_layout_merged_cell_spans_and_covers_its_range() { + let (mut engine, table, row0, _row1, col0, col1) = projection_table_engine(); + // Merges use stable row/column ids, never positional indexes. + engine.merge_table_cells("t", table, row0.clone(), col0, row0, col1); + let table = only_table(&engine); + let anchor = table.cell(0, 0).unwrap(); + assert_eq!((anchor.row_span, anchor.col_span), (1, 2)); + assert_eq!( + anchor.rect.size, + dvec2(2.0 * TABLE_CELL_WIDTH, TABLE_CELL_HEIGHT) + ); + assert_eq!(anchor.text, "qty"); + let covered = table.cell(0, 1).unwrap(); + assert!(covered.covered); + assert!(covered.text.is_empty()); + // A point inside the covered region resolves to the merge anchor. + let inside_covered = dvec2(LAYOUT_MARGIN + 1.5 * TABLE_CELL_WIDTH, LAYOUT_MARGIN + 4.0); + assert_eq!(table.hit(inside_covered), Some((0, 0))); +} + +#[test] +fn projection_layout_hit_test_maps_points_to_cell_coordinates() { + let (engine, ..) = projection_table_engine(); + let tree = layout_projection(&engine.projection); + let cell_11 = dvec2( + LAYOUT_MARGIN + 1.5 * TABLE_CELL_WIDTH, + LAYOUT_MARGIN + 1.5 * TABLE_CELL_HEIGHT, + ); + assert_eq!(tree.table_hit_test(cell_11), Some((0, 1, 1))); + let cell_00 = dvec2(LAYOUT_MARGIN + 1.0, LAYOUT_MARGIN + 1.0); + assert_eq!(tree.table_hit_test(cell_00), Some((0, 0, 0))); + // Below the table there is no cell. + let below = dvec2( + LAYOUT_MARGIN + 1.0, + LAYOUT_MARGIN + 2.0 * TABLE_CELL_HEIGHT + 40.0, + ); + assert_eq!(tree.table_hit_test(below), None); +} + +#[test] +fn projection_layout_advances_blocks_below_the_table() { + let (mut engine, ..) = projection_table_engine(); + let paragraph = engine.insert_block("t", None, "paragraph").unwrap(); + engine.insert_text("t", paragraph, None, "after"); + let tree = layout_projection(&engine.projection); + let table = &tree.tables[0]; + let table_bottom = table.rect.pos.y + table.rect.size.y; + // The paragraph is block 1; its origin must clear the table plus gap. + let paragraph_origin = tree.block_origins[1]; + assert_eq!(paragraph_origin.y, table_bottom + TABLE_BOTTOM_GAP); + // Its glyphs share the shifted line, not the legacy fixed line step. + let first_glyph = tree + .glyphs + .iter() + .find(|glyph| glyph.rect.pos.y > table_bottom) + .unwrap(); + assert_eq!(first_glyph.rect.pos.y, paragraph_origin.y); +} + +#[test] +fn projection_layout_interleaves_advanced_nodes_in_unified_order() { + let mut engine = CrdtController::default(); + let p1 = engine.insert_block("t", None, "paragraph").unwrap(); + engine.insert_text("t", p1.clone(), None, "before"); + // Anchoring the node after p1, and p2 after the node, interleaves them. + let canvas = engine + .insert_node("t", None, Some(p1.clone()), "canvas", "{}") + .unwrap(); + let p2 = engine + .insert_block("t", Some(canvas.clone()), "paragraph") + .unwrap(); + engine.insert_text("t", p2.clone(), None, "after"); + let tree = layout_projection(&engine.projection); + assert_eq!(tree.nodes.len(), 1); + let node = &tree.nodes[0]; + assert_eq!(node.kind, "canvas"); + assert_eq!(node.node_id, format!("{}:{}", canvas.actor, canvas.counter)); + // p1 holds the first line; the canvas placeholder sits right below it. + assert_eq!( + node.rect.pos, + dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN + LINE_HEIGHT) + ); + assert_eq!(node.rect.size, dvec2(ADVANCED_NODE_WIDTH, 240.0)); + assert_eq!(node.label, "Canvas"); + assert!(node.interactive); + // p2's origin clears the node plus its bottom gap. + let p2_index = engine + .projection + .blocks + .iter() + .position(|block| block.id == format!("{}:{}", p2.actor, p2.counter)) + .unwrap(); + assert_eq!( + tree.block_origins[p2_index], + dvec2( + LAYOUT_MARGIN, + node.rect.pos.y + node.rect.size.y + ADVANCED_NODE_BOTTOM_GAP + ) + ); + // The "after" glyphs sit on p2's shifted line, not the legacy fixed step. + let last_glyph = tree.glyphs.last().unwrap(); + assert_eq!(last_glyph.block, p2); + assert_eq!(last_glyph.rect.pos.y, tree.block_origins[p2_index].y); +} + +#[test] +fn projection_layout_node_metrics_match_legacy_advanced_layout() { + assert_eq!( + projected_node_metrics("image"), + (220.0, "Image".to_string(), true) + ); + assert_eq!( + projected_node_metrics("canvas"), + (240.0, "Canvas".to_string(), true) + ); + assert_eq!( + projected_node_metrics("divider"), + (18.0, "Divider".to_string(), false) + ); + assert_eq!( + projected_node_metrics("audio"), + (52.0, "Audio".to_string(), true) + ); + assert_eq!( + projected_node_metrics("video"), + (180.0, "Video".to_string(), true) + ); + assert_eq!( + projected_node_metrics("diagram"), + (160.0, "Diagram".to_string(), true) + ); + assert_eq!( + projected_node_metrics("quote"), + (56.0, "Quote".to_string(), false) + ); + assert_eq!( + projected_node_metrics("page_break"), + (36.0, "Page Break".to_string(), false) + ); + // Unknown kinds follow the bridge's EmbeddedWidget mapping. + assert_eq!( + projected_node_metrics("weather-pill"), + (64.0, "Widget: weather-pill".to_string(), true) + ); +} + +#[test] +fn projection_layout_node_hit_test_resolves_points_to_nodes() { + let mut engine = CrdtController::default(); + engine.insert_node("t", None, None, "image", "{}"); + let tree = layout_projection(&engine.projection); + let inside = dvec2(LAYOUT_MARGIN + 40.0, LAYOUT_MARGIN + 40.0); + assert_eq!(tree.node_hit_test(inside), Some(0)); + // Beyond the placeholder column there is no node. + let outside = dvec2( + LAYOUT_MARGIN + ADVANCED_NODE_WIDTH + 40.0, + LAYOUT_MARGIN + 1.0, + ); + assert_eq!(tree.node_hit_test(outside), None); +} + +#[test] +fn projection_layout_stacks_tables_nodes_and_text_in_one_column() { + let (mut engine, table, ..) = projection_table_engine(); + engine.insert_node("t", None, Some(table), "image", "{}"); + let tree = layout_projection(&engine.projection); + let table_layout = &tree.tables[0]; + let node = &tree.nodes[0]; + assert_eq!(node.kind, "image"); + // The image placeholder starts below the table plus its gap. + assert_eq!( + node.rect.pos.y, + table_layout.rect.pos.y + table_layout.rect.size.y + TABLE_BOTTOM_GAP + ); +} + +/// Engine with one paragraph reading "the quick brown fox": word starts +/// are at chars 0/4/10/16 for `word_atom_range` assertions. +fn projection_word_engine() -> CrdtController { + let mut engine = CrdtController::default(); + let block = engine.insert_block("t", None, "paragraph").unwrap(); + engine.insert_text("t", block, None, "the quick brown fox"); + engine +} + +#[test] +fn word_atom_range_selects_the_whitespace_delimited_word() { + let engine = projection_word_engine(); + let atoms = &engine.projection.blocks[0].runs[0].atoms; + // Any atom inside "quick" (chars 4..=8) selects the whole word. + for pivot in 4..=8 { + let (start, end) = word_atom_range(&engine.projection, &atoms[pivot]).unwrap(); + assert_eq!(start, atoms[4]); + assert_eq!(end, atoms[8]); + } +} + +#[test] +fn word_atom_range_picks_the_preceding_word_on_whitespace() { + let engine = projection_word_engine(); + let atoms = &engine.projection.blocks[0].runs[0].atoms; + // The space after "quick" resolves to "quick", matching legacy + // word_bounds pivot behaviour. + let (start, end) = word_atom_range(&engine.projection, &atoms[9]).unwrap(); + assert_eq!(start, atoms[4]); + assert_eq!(end, atoms[8]); +} + +#[test] +fn word_atom_range_at_line_end_selects_the_last_word() { + let engine = projection_word_engine(); + let atoms = &engine.projection.blocks[0].runs[0].atoms; + let last = atoms.len() - 1; + let (start, end) = word_atom_range(&engine.projection, &atoms[last]).unwrap(); + assert_eq!(start, atoms[16]); + assert_eq!(end, atoms[18]); +} + +#[test] +fn selection_handles_normalize_into_document_order() { + let mut engine = CrdtController::default(); + let block = engine.insert_block("t", None, "paragraph").unwrap(); + engine.insert_text("t", block, None, "abc"); + let layout = layout_projection(&engine.projection); + let glyph = |index: usize| &layout.glyphs[index]; + // A backwards drag (anchor on 'c', focus on 'a') still reports the + // start handle on 'a' and the end handle on 'c'. + let handles = selection_handles(&layout, &glyph(2).atom, &glyph(0).atom).unwrap(); + assert_eq!(handles.start_atom, glyph(0).atom); + assert_eq!(handles.end_atom, glyph(2).atom); + assert_eq!(handles.start_rect.pos.x, glyph(0).rect.pos.x - 8.0); + assert_eq!( + handles.end_rect.pos.x, + glyph(2).rect.pos.x + glyph(2).rect.size.x - 2.0 + ); + // A collapsed selection (anchor == focus) shows a caret, not handles. + assert!(selection_handles(&layout, &glyph(1).atom, &glyph(1).atom).is_none()); +} + +#[test] +fn selection_handle_hit_test_uses_the_touch_slop() { + let mut engine = CrdtController::default(); + let block = engine.insert_block("t", None, "paragraph").unwrap(); + engine.insert_text("t", block, None, "abc"); + let layout = layout_projection(&engine.projection); + let handles = + selection_handles(&layout, &layout.glyphs[0].atom, &layout.glyphs[2].atom).unwrap(); + assert_eq!( + handles.handle_at(handles.start_rect.pos + dvec2(1.0, 1.0)), + Some(true) + ); + assert_eq!( + handles.handle_at(handles.end_rect.pos + dvec2(1.0, 1.0)), + Some(false) + ); + // Just outside the rect is still a hit, within the slop. + let slop = SELECTION_HANDLE_TOUCH_SLOP - 2.0; + let near = dvec2( + handles.start_rect.pos.x - slop, + handles.start_rect.pos.y - slop, + ); + assert_eq!(handles.handle_at(near), Some(true)); + // Well past the slop there is no handle. + let far = dvec2( + handles.start_rect.pos.x - 40.0, + handles.start_rect.pos.y - 40.0, + ); + assert_eq!(handles.handle_at(far), None); +} + +/// Two-block engine ("ab" / "cd") for glyph-stream stepping assertions. +fn projection_two_block_engine() -> CrdtController { + let mut engine = CrdtController::default(); + let first = engine.insert_block("t", None, "paragraph").unwrap(); + engine.insert_text("t", first.clone(), None, "ab"); + let second = engine.insert_block("t", Some(first), "paragraph").unwrap(); + engine.insert_text("t", second, None, "cd"); + engine +} + +#[test] +fn step_glyph_crosses_block_boundaries_in_unified_order() { + let engine = projection_two_block_engine(); + let layout = layout_projection(&engine.projection); + // Stream is [a, b, c, d], blocks [ab] [cd]: stepping past 'b' lands on + // 'c' in the next block without block-aware special cases. + let b_atom = layout.glyphs[1].atom.clone(); + let c_glyph = step_glyph(&layout, &b_atom, true).unwrap(); + assert_eq!(c_glyph.atom, layout.glyphs[2].atom); + assert_ne!(c_glyph.block, layout.glyphs[1].block); + let back = step_glyph(&layout, &c_glyph.atom.clone(), false).unwrap(); + assert_eq!(back.atom, b_atom); + // Document edges yield no glyph. + assert!(step_glyph(&layout, &layout.glyphs[0].atom, false).is_none()); + assert!(step_glyph(&layout, &layout.glyphs[3].atom, true).is_none()); +} + +#[test] +fn block_glyph_offset_positions_atoms_inside_their_block() { + let engine = projection_two_block_engine(); + let layout = layout_projection(&engine.projection); + let first_block = layout.glyphs[0].block.clone(); + assert_eq!( + block_glyph_offset(&engine.projection, &first_block, &layout.glyphs[0].atom), + Some(0) + ); + assert_eq!( + block_glyph_offset(&engine.projection, &first_block, &layout.glyphs[1].atom), + Some(1) + ); + // 'c' lives in the second block: querying it against the first fails. + let second_block = layout.glyphs[2].block.clone(); + assert_eq!( + block_glyph_offset(&engine.projection, &first_block, &layout.glyphs[2].atom), + None + ); + assert_eq!( + block_glyph_offset(&engine.projection, &second_block, &layout.glyphs[2].atom), + Some(0) + ); + assert_eq!(glyph_index_of(&layout, &layout.glyphs[2].atom), Some(2)); +} + +#[test] +fn parse_op_id_round_trips_and_rejects_garbage() { + let id = OpId { + actor: "peer-7".to_string(), + counter: 42, + }; + let parsed = parse_op_id(&format!("{}:{}", id.actor, id.counter)).unwrap(); + assert_eq!(parsed, id); + assert!(parse_op_id("no-counter").is_none()); + assert!(parse_op_id("a:notanumber").is_none()); + assert!(parse_op_id("").is_none()); +} + +/// Engine with styled text, an aligned paragraph, a populated table and an +/// image node — the full surface the workspace save button must preserve. +fn crdt_save_wire_engine() -> (CrdtController, OpId) { + let mut engine = CrdtController::default(); + let block = engine.insert_block("t", None, "paragraph").unwrap(); + engine.insert_text("t", block.clone(), None, "hello crdt world"); + engine.toggle_text_style_at_offsets("t", block.clone(), 0, 5, "bold"); + engine.set_block_alignment("t", block.clone(), "Center"); + let table = engine.insert_table("t", Some(block.clone())).unwrap(); + let row = engine.insert_table_row("t", table.clone(), None).unwrap(); + let col = engine + .insert_table_column("t", table.clone(), None) + .unwrap(); + engine.set_table_cell("t", table.clone(), row, col, "two words"); + engine + .insert_node("t", None, Some(table), "image", "caption") + .unwrap(); + (engine, block) +} + +#[test] +fn crdt_save_wire_round_trips_engine_state() { + let (engine, block) = crdt_save_wire_engine(); + let wire = crdt_save_wire(&engine.document).expect("wire serializes"); + assert!(wire.starts_with(CRDT_SAVE_HEADER)); + let restored = crdt_engine_from_saved(&wire).expect("wire parses"); + // Same block sequence, text and alignment. + let ids: Vec = engine + .projection + .blocks + .iter() + .map(|b| b.text.clone()) + .collect(); + let restored_ids: Vec = restored + .projection + .blocks + .iter() + .map(|b| b.text.clone()) + .collect(); + assert_eq!(restored_ids, ids); + let id = format!("{}:{}", block.actor, block.counter); + let aligned = restored + .projection + .blocks + .iter() + .find(|b| b.id == id) + .unwrap(); + assert_eq!(aligned.alignment, "Center"); + // Styled runs survive the wire. + let original_runs = engine.projection.blocks[0].runs.len(); + assert_eq!(aligned.runs.len(), original_runs); + assert!(aligned.runs.iter().any(|run| run.bold)); + // Table cells and the advanced node survive too. + assert_eq!(restored.projection.tables.len(), 1); + let table = restored.projection.tables.values().next().unwrap(); + assert!(table.cells.values().any(|cell| cell == "two words")); + assert_eq!(restored.projection.nodes.len(), 1); + assert_eq!(restored.projection.nodes[0].kind, "image"); + assert_eq!(restored.projection.order, engine.projection.order); +} + +#[test] +fn crdt_engine_from_saved_rejects_legacy_delimiter_and_garbage() { + // Legacy delimiter documents stay with the classic editor's migration. + assert!( + crdt_engine_from_saved("M|local|1|1\nP|Left|hello§false§false§false§12.0§~\n").is_none() + ); + assert!(crdt_engine_from_saved("#MP_CRDT_V1\n{not json").is_none()); + assert!(crdt_engine_from_saved("").is_none()); + assert!(crdt_engine_from_saved("#MP_CRDT_V1\n").is_none()); +} + +#[test] +fn projected_stats_counts_words_and_chars_in_blocks_and_tables() { + let (engine, _) = crdt_save_wire_engine(); + let (words, chars) = projected_stats(&engine.projection); + // "hello crdt world" (3 words, 16 chars) + table cell "two words" + // (2 words, 9 chars); the image node contributes nothing. + assert_eq!(words, 5); + assert_eq!(chars, 25); + let empty = CrdtController::default(); + assert_eq!(projected_stats(&empty.projection), (0, 0)); +} + +#[test] +fn projection_layout_falls_back_to_block_order_when_order_is_empty() { + // A projection assembled without the operation log has no unified order. + let mut projection = doc_engine::projection::DocumentProjection::default(); + for index in 0..2 { + projection + .blocks + .push(doc_engine::projection::ProjectedBlock { + id: format!("a:{}", index + 1), + kind: "paragraph".to_string(), + text: "x".to_string(), + ..Default::default() + }); + } + let tree = layout_projection(&projection); + assert_eq!(tree.block_origins[0], dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN)); + assert_eq!( + tree.block_origins[1], + dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN + LINE_HEIGHT) + ); + assert!(tree.nodes.is_empty()); + assert!(tree.tables.is_empty()); +} + +// ---- persistence store migration -------------------------------------- + +fn temp_store_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "nigig-doc-persistence-{}-{tag}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +#[test] +fn saved_doc_round_trips_through_runtime_store() { + let store = temp_store_dir("round-trip"); + save_doc_state_to(store.clone(), GENERATED_DOC_FILE, "{\"doc\":\"state\"}").expect("save"); + assert_eq!( + load_saved_doc_state_with(store.clone(), store.join("no-manifest-here")), + Some("{\"doc\":\"state\"}".to_string()) + ); + let _ = std::fs::remove_dir_all(&store); +} + +#[test] +fn runtime_store_takes_precedence_over_manifest_fallback() { + let store = temp_store_dir("store"); + let manifest = temp_store_dir("manifest"); + save_doc_state_to(store.clone(), GENERATED_DOC_FILE, "store version").expect("store save"); + save_doc_state_to(manifest.clone(), GENERATED_DOC_FILE, "manifest version") + .expect("manifest save"); + assert_eq!( + load_saved_doc_state_with(store.clone(), manifest.clone()), + Some("store version".to_string()), + "a document in the app-data store wins over the legacy source-tree copy" + ); + let _ = std::fs::remove_dir_all(&store); + let _ = std::fs::remove_dir_all(&manifest); +} + +#[test] +fn manifest_fallback_serves_legacy_saves_once() { + let store = temp_store_dir("empty-store"); + let manifest = temp_store_dir("legacy-manifest"); + // No file in the runtime store: the legacy source-tree copy is still + // honored (it is read-only now — new saves never go there). + save_doc_state_to(manifest.clone(), GENERATED_DOC_FILE, "legacy save").expect("manifest save"); + assert_eq!( + load_saved_doc_state_with(store.clone(), manifest.clone()), + Some("legacy save".to_string()) + ); + let _ = std::fs::remove_dir_all(&store); + let _ = std::fs::remove_dir_all(&manifest); +} + +#[test] +fn empty_or_missing_save_files_boot_fresh() { + let store = temp_store_dir("empty-file-store"); + let manifest = temp_store_dir("missing-manifest"); + save_doc_state_to(store.clone(), GENERATED_DOC_FILE, " \n").expect("save"); + // An empty/whitespace save is treated as absent: the workspace boots + // fresh (demo seed) rather than feeding the loader a blank document. + assert_eq!( + load_saved_doc_state_with(store.clone(), manifest.clone()), + None + ); + // And with nothing anywhere there is nothing to load. + let bare = temp_store_dir("bare"); + assert_eq!( + load_saved_doc_state_with(bare.clone(), bare.join("none")), + None + ); + let _ = std::fs::remove_dir_all(&store); + let _ = std::fs::remove_dir_all(&manifest); + let _ = std::fs::remove_dir_all(&bare); +} + +// == Mobile gesture router (host-only state machine) ===================== + +use super::mobile_gesture::{MobileGestureAction, MobileGestureRouter, MobileGestureState}; + +#[test] +fn gesture_short_tap_yields_passive_cursor_and_resets() { + let mut router = MobileGestureRouter::default(); + assert_eq!( + router.begin(dvec2(5.0, 5.0), None), + MobileGestureAction::None + ); + assert_eq!(router.state, MobileGestureState::PendingLongPress); + assert_eq!(router.end(), MobileGestureAction::MovePassiveCursor); + assert_eq!(router.state, MobileGestureState::Idle); +} + +#[test] +fn gesture_long_press_fires_word_select_after_the_frame_budget() { + let mut router = MobileGestureRouter::default(); + router.begin(dvec2(5.0, 5.0), None); + for frame in 1..24 { + assert_eq!( + router.tick(), + MobileGestureAction::None, + "frame {frame} is still pending" + ); + } + assert_eq!(router.tick(), MobileGestureAction::SelectWord); + assert_eq!(router.state, MobileGestureState::Selecting); + // In Selecting the drag yields no action (the widget owns tracking). + assert_eq!(router.move_to(dvec2(50.0, 5.0)), MobileGestureAction::None); + assert_eq!(router.end(), MobileGestureAction::None); +} + +#[test] +fn gesture_moving_past_threshold_routes_to_scroll_and_stays_there() { + let mut router = MobileGestureRouter::default(); + router.begin(dvec2(0.0, 0.0), None); + assert_eq!( + router.move_to(dvec2(11.0, 0.0)), + MobileGestureAction::PassToScroll, + "an 11px move beats the 10px slop" + ); + assert_eq!(router.state, MobileGestureState::Scrolling); + assert_eq!( + router.move_to(dvec2(100.0, 0.0)), + MobileGestureAction::PassToScroll + ); + assert_eq!(router.tick(), MobileGestureAction::None); + assert_eq!(router.end(), MobileGestureAction::None); + assert_eq!(router.state, MobileGestureState::Idle); +} + +#[test] +fn gesture_small_jitter_under_threshold_keeps_pending() { + let mut router = MobileGestureRouter::default(); + router.begin(dvec2(0.0, 0.0), None); + assert_eq!(router.move_to(dvec2(9.0, 0.0)), MobileGestureAction::None); + assert_eq!(router.state, MobileGestureState::PendingLongPress); +} + +#[test] +fn gesture_handle_grabs_adjust_and_lift_cleanly() { + let mut router = MobileGestureRouter::default(); + router.begin(dvec2(0.0, 0.0), Some(true)); + assert_eq!(router.state, MobileGestureState::AdjustingStartHandle); + assert_eq!( + router.move_to(dvec2(3.0, 3.0)), + MobileGestureAction::AdjustStart + ); + assert_eq!(router.tick(), MobileGestureAction::None); + router.end(); + router.begin(dvec2(0.0, 0.0), Some(false)); + assert_eq!(router.state, MobileGestureState::AdjustingEndHandle); + assert_eq!( + router.move_to(dvec2(3.0, 3.0)), + MobileGestureAction::AdjustEnd + ); + assert_eq!(router.end(), MobileGestureAction::None); + assert_eq!(router.state, MobileGestureState::Idle); +} + +// == persistence path wiring (read-only asserts) ========================= + +#[test] +fn doc_store_paths_follow_the_store_convention() { + assert_eq!( + super::persistence::doc_store_dir().file_name().unwrap(), + "nigig_build_store" + ); + let generated = super::persistence::doc_generated_dir_path(); + assert_eq!(generated.file_name().unwrap(), "generated"); + assert_eq!( + generated.parent().unwrap().file_name().unwrap(), + "nigig_build_store" + ); +} + +// == plugins registry ====================================================== + +#[test] +fn plugin_registry_registers_and_reports() { + let mut registry = super::plugins::PluginRegistry::default(); + assert!(!registry.contains("weather-pill")); + registry.register( + "weather-pill", + super::plugins::BlockPluginDescriptor { + display_name: "Weather".into(), + version: 2, + supports_inline: true, + supports_focus: false, + }, + ); + assert!(registry.contains("weather-pill")); + let descriptor = registry.blocks.get("weather-pill").unwrap(); + assert_eq!(descriptor.version, 2); + assert!(descriptor.supports_inline); +} + +// == collaboration session/transport edges ================================ + +#[test] +fn collaboration_make_local_sequences_and_dedupes_replays() { + let mut session = CollaborationSession::new("local"); + let op1 = session.make_local(0, super::editing::Transaction { commands: vec![] }); + let op2 = session.make_local(1, super::editing::Transaction { commands: vec![] }); + assert_eq!(op1.id.sequence + 1, op2.id.sequence); + assert_eq!(session.outbound.len(), 2); + // A replay of an operation this peer already issued or accepted is + // rejected; novel ones pass exactly once. + assert!(!session.accept_remote(&op1)); + assert!(!session.accept_remote(&op2)); +} + +#[test] +fn collaboration_frontier_is_monotonic_per_actor() { + let mut session = CollaborationSession::new("local"); + session.acknowledge_frontier("a".into(), 10); + session.acknowledge_frontier("a".into(), 4); + assert_eq!(session.acknowledged_frontiers["a"], 10, "acks only advance"); + assert_eq!(session.safe_frontier(), Some(10)); +} + +#[test] +fn collaboration_presence_replaces_by_actor() { + let mut session = CollaborationSession::new("local"); + session.update_presence(Presence { + actor: "p".into(), + display_name: "Pat".into(), + ..Default::default() + }); + session.update_presence(Presence { + actor: "p".into(), + display_name: "Patricia".into(), + ..Default::default() + }); + assert_eq!(session.presences.len(), 1); + assert_eq!(session.presences["p"].display_name, "Patricia"); +} + +#[test] +fn memory_transport_full_duplex_loopback_drains_every_channel() { + let mut transport = MemoryTransport::default(); + let mut session = CollaborationSession::new("local"); + let op = session.make_local(0, super::editing::Transaction { commands: vec![] }); + transport.send_operation(op.clone()); + transport.send_presence(Presence { + actor: "local".into(), + ..Default::default() + }); + transport.send_ack(AckMessage { + actor: "local".into(), + counter: 3, + }); + transport + .incoming_operations + .extend(transport.outgoing_operations.drain(..)); + transport + .incoming_presences + .extend(transport.outgoing_presences.drain(..)); + transport + .incoming_acks + .extend(transport.outgoing_acks.drain(..)); + assert_eq!(transport.receive_operations()[0].id, op.id); + assert_eq!(transport.receive_presences()[0].actor, "local"); + assert_eq!(transport.receive_acks()[0].counter, 3); + assert!(transport.receive_operations().is_empty(), "receive drains"); +} + +// == model::crdt sequences ================================================ + +#[test] +fn rga_text_legacy_seed_is_deterministic_across_processes() { + let a = RgaText::from_legacy(2, 1, "seed"); + let b = RgaText::from_legacy(2, 1, "seed"); + assert_eq!(a.visible_string(), "seed"); + assert_eq!( + a.atoms.keys().collect::>(), + b.atoms.keys().collect::>() + ); + let other = RgaText::from_legacy(2, 0, "seed"); + assert_ne!( + a.atoms.keys().collect::>(), + other.atoms.keys().collect::>(), + "span changes the seed ids" + ); +} + +#[test] +fn rga_text_concurrent_children_order_by_atom_id() { + let mut rga = RgaText::from_legacy(0, 0, "A"); + let root = rga.visible_atoms()[0].id.clone(); + rga.insert(TextAtom { + id: AtomId { + actor: "z".into(), + sequence: 1, + }, + after: Some(root.clone()), + ch: 'Z', + deleted: false, + deleted_at: None, + timestamp: LamportTimestamp::default(), + }); + rga.insert(TextAtom { + id: AtomId { + actor: "b".into(), + sequence: 1, + }, + after: Some(root.clone()), + ch: 'B', + deleted: false, + deleted_at: None, + timestamp: LamportTimestamp::default(), + }); + assert_eq!( + rga.visible_string(), + "ABZ", + "same-anchor siblings sort by AtomId" + ); + // Inserts are idempotent by id, not merge-by-content. + assert!(!rga.insert(TextAtom { + id: AtomId { + actor: "b".into(), + sequence: 1 + }, + after: Some(root), + ch: '!', + deleted: false, + deleted_at: None, + timestamp: LamportTimestamp::default(), + })); + assert_eq!(rga.visible_string(), "ABZ"); +} + +#[test] +fn rga_text_tombstones_hide_and_compact() { + let mut rga = RgaText::from_legacy(0, 0, "abc"); + let ids: Vec<_> = rga.visible_atoms().iter().map(|a| a.id.clone()).collect(); + rga.tombstone_at([ids[1].clone()], LamportTimestamp { counter: 5 }); + assert_eq!(rga.visible_string(), "ac"); + rga.tombstone_at([ids[1].clone()], LamportTimestamp { counter: 9 }); + assert_eq!( + rga.atoms[&ids[1]].deleted_at, + Some(LamportTimestamp { counter: 9 }), + "re-tombstone moves the watermark" + ); + rga.compact_tombstones_before(LamportTimestamp { counter: 8 }); + assert!( + rga.atoms.contains_key(&ids[1]), + "a 9-watermarked tombstone survives an 8 frontier" + ); + rga.compact_tombstones_before(LamportTimestamp { counter: 10 }); + assert!( + !rga.atoms.contains_key(&ids[1]), + "compaction drops tombstones below the frontier" + ); + assert!(rga.atoms.contains_key(&ids[0])); +} + +#[test] +fn crdt_metadata_clock_advances_and_observes_peers() { + let mut meta = CrdtMetadata { + local_actor: "me".into(), + next_sequence: 0, + clock: LamportTimestamp::default(), + }; + let first = meta.next_atom_id(); + let second = meta.next_atom_id(); + assert_eq!(first.actor, "me"); + let _ = (first, second); + meta.observe(LamportTimestamp { counter: 41 }); + assert_eq!(meta.clock.counter, 42); + meta.observe(LamportTimestamp { counter: 2 }); + assert_eq!( + meta.clock.counter, 43, + "observe is a NEXT-clock: max(seen, local) + 1, never a rewind" + ); +} + +#[test] +fn block_order_crdt_orders_hides_and_stays_idempotent() { + let mut order = BlockOrderCrdt::default(); + let a = BlockId(AtomId { + actor: "x".into(), + sequence: 1, + }); + let b = BlockId(AtomId { + actor: "x".into(), + sequence: 2, + }); + assert!(order.insert(BlockAtom { + id: a.clone(), + after: None, + deleted: false, + deleted_at: None, + timestamp: LamportTimestamp::default(), + })); + assert!(!order.insert(BlockAtom { + id: a.clone(), + after: None, + deleted: false, + deleted_at: None, + timestamp: LamportTimestamp::default(), + })); + assert!(order.insert(BlockAtom { + id: b.clone(), + after: Some(a.clone()), + deleted: false, + deleted_at: None, + timestamp: LamportTimestamp::default(), + })); + assert_eq!(order.visible_ids(), vec![a.clone(), b.clone()]); + order.tombstone(&a); + assert_eq!(order.visible_ids(), vec![b]); +} + +// == model::document mutation bookkeeping ================================== + +#[test] +fn legacy_block_ids_stay_parallel_to_blocks() { + let mut document = Document::new(vec![ + DocBlock::Paragraph { + align: DocAlign::Left, + spans: vec![], + }, + DocBlock::Divider, + ]); + document.ensure_legacy_block_ids(); + let first = document.legacy_block_ids.clone(); + assert_eq!(first.len(), 2); + document.ensure_legacy_block_ids(); + assert_eq!(document.legacy_block_ids, first, "idempotent seeding"); +} + +#[test] +fn legacy_insert_remove_tracks_revisions_and_ids() { + let mut document = Document::new(vec![]); + document.ensure_legacy_block_ids(); + assert!(document.insert_legacy_block(0, DocBlock::Divider)); + assert!(document.insert_legacy_block( + 1, + DocBlock::Paragraph { + align: DocAlign::Left, + spans: vec![] + } + )); + assert_eq!(document.blocks.len(), 2); + assert_eq!(document.legacy_block_ids.len(), 2); + assert!( + !document.insert_legacy_block(9, DocBlock::Divider), + "out-of-range insert refused" + ); + let removed = document.remove_legacy_block(0); + assert!(matches!(removed, Some(DocBlock::Divider))); + assert_eq!(document.blocks.len(), 1); + assert_eq!(document.legacy_block_ids.len(), 1); + assert!(document.remove_legacy_block(5).is_none()); +} + +#[test] +fn document_touch_lifts_revisions() { + let mut document = Document::new(vec![DocBlock::Divider]); + document.ensure_legacy_block_ids(); + assert_eq!(document.block_revision(0), 0); + document.touch_block(0); + assert_eq!(document.block_revision(0), 1); + assert_eq!( + document.metadata.revision, 1, + "per-block touches also advance the document revision" + ); + document.touch(); + assert_eq!(document.metadata.revision, 2); + document.append_node(DocumentNode { + id: 1, + style: Default::default(), + kind: BlockKind::Divider, + }); + assert_eq!(document.nodes.len(), 1); +} + +// == editing: command inverses, controller, transactions =================== + +use super::editing::{Command, DocumentController as LegacyController, History, Transaction}; + +/// A document with one seeded paragraph and a session to drive edit +/// commands against, mirroring how the legacy widget wires the pair. +fn editing_fixture() -> (Document, DocumentSession) { + let document = Document::new(vec![DocBlock::Paragraph { + align: DocAlign::Left, + spans: vec![StyleSpan { + text: "seed".to_string(), + ..Default::default() + }], + }]); + (document, DocumentSession::default()) +} + +#[test] +fn command_insert_block_and_remove_block_are_exact_inverses() { + let (mut document, mut session) = editing_fixture(); + let insert = Command::InsertBlock { + index: 1, + block: DocBlock::Divider, + }; + let inverse = insert.apply(&mut document, &mut session).expect("inverse"); + assert_eq!(document.blocks.len(), 2); + let reinsert = inverse + .apply(&mut document, &mut session) + .expect("inverse of inverse"); + assert_eq!(document.blocks.len(), 1, "undo removes the inserted block"); + drop(reinsert); +} + +#[test] +fn command_set_alignment_round_trips_through_inverse() { + let (mut document, mut session) = editing_fixture(); + let inverse = Command::SetAlignment { + block_idx: 0, + align: DocAlign::Center, + } + .apply(&mut document, &mut session) + .unwrap(); + match &document.blocks[0] { + DocBlock::Paragraph { align, .. } => assert_eq!(*align, DocAlign::Center), + other => panic!("expected paragraph, got {other:?}"), + } + inverse.apply(&mut document, &mut session); + match &document.blocks[0] { + DocBlock::Paragraph { align, .. } => assert_eq!(*align, DocAlign::Left), + other => panic!("expected paragraph, got {other:?}"), + } +} + +#[test] +fn command_replace_spans_restores_previous_spans() { + let (mut document, mut session) = editing_fixture(); + let inverse = Command::ReplaceSpans { + block_idx: 0, + spans: vec![StyleSpan { + text: "replaced".to_string(), + ..Default::default() + }], + } + .apply(&mut document, &mut session) + .unwrap(); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "replaced"), + other => panic!("expected paragraph"), + } + inverse.apply(&mut document, &mut session); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "seed"), + other => panic!("expected paragraph"), + } +} + +#[test] +fn command_restore_document_swaps_everything_back() { + let (mut document, mut session) = editing_fixture(); + let replacement = Document::new(vec![DocBlock::Divider]); + let inverse = Command::restore(replacement, DocumentSession::default()) + .apply(&mut document, &mut session) + .unwrap(); + assert_eq!(document.blocks.len(), 1); + assert!(matches!(document.blocks[0], DocBlock::Divider)); + inverse.apply(&mut document, &mut session); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "seed"), + other => panic!("restore returned the original document, got {other:?}"), + } +} + +#[test] +fn command_affected_blocks_and_crdt_safety_classify_variants() { + let at = DocCursor { + block_idx: 3, + cell_pos: None, + span_idx: 0, + char_offset: 0, + }; + assert_eq!( + Command::InsertText { + at, + text: "x".into() + } + .affected_blocks(), + vec![3] + ); + assert_eq!( + Command::DeleteText { + at, + text: "x".into() + } + .affected_blocks(), + vec![3] + ); + assert_eq!( + Command::SetAlignment { + block_idx: 2, + align: DocAlign::Left + } + .affected_blocks(), + vec![2] + ); + assert!(Command::InsertBlock { + index: 1, + block: DocBlock::Divider + } + .affected_blocks() + .contains(&1)); + assert!(Command::InsertBlockAfter { + after: None, + block_id: BlockId(AtomId { + actor: "a".into(), + sequence: 1 + }), + block: DocBlock::Divider + } + .affected_blocks() + .is_empty()); + // CRDT-safe variants commute with any base revision; index-based + // edits must buffer until their base arrives. + assert!(Command::InsertAtoms { + block_idx: 0, + span_idx: 0, + atoms: vec![] + } + .is_crdt_safe()); + assert!(Command::DeleteAtoms { + block_idx: 0, + span_idx: 0, + ids: vec![] + } + .is_crdt_safe()); + assert!(!Command::InsertText { + at, + text: "x".into() + } + .is_crdt_safe()); + assert!(!Command::RestoreDocument { + document: Document::default(), + session: DocumentSession::default() + } + .is_crdt_safe()); +} + +#[test] +fn command_insert_atoms_seeds_legacy_text_into_crdt() { + let (mut document, mut session) = editing_fixture(); + let inverse = Command::InsertAtoms { + block_idx: 0, + span_idx: 0, + atoms: vec![TextAtom { + id: AtomId { + actor: "peer".into(), + sequence: 1, + }, + after: None, + ch: 'X', + deleted: false, + deleted_at: None, + timestamp: LamportTimestamp::default(), + }], + } + .apply(&mut document, &mut session) + .expect("inverse"); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => { + assert_eq!( + spans[0].text, "seedX", + "the seeded prefix wins RGA sibling order; the peer atom lands after it" + ); + assert!( + spans[0].crdt_text.is_some(), + "legacy text is now atom-backed" + ); + } + other => panic!("expected paragraph"), + } + // The inverse deletes exactly what the insert added. + inverse.apply(&mut document, &mut session); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "seed"), + other => panic!("expected paragraph"), + } +} + +#[test] +fn controller_execute_transaction_undoes_as_one_step() { + let mut controller = + LegacyController::with_document(Document::new(vec![DocBlock::Paragraph { + align: DocAlign::Left, + spans: vec![StyleSpan { + text: "a".into(), + ..Default::default() + }], + }])); + assert!(controller.execute_transaction(vec![ + Command::SetAlignment { + block_idx: 0, + align: DocAlign::Center + }, + Command::ReplaceSpans { + block_idx: 0, + spans: vec![StyleSpan { + text: "b".into(), + ..Default::default() + }] + }, + ])); + assert!(controller.undo(), "one undo reverses the whole transaction"); + match &controller.document.blocks[0] { + DocBlock::Paragraph { align, spans } => { + assert_eq!(*align, DocAlign::Left); + assert_eq!(spans[0].text, "a"); + } + other => panic!("expected paragraph"), + } + assert!(controller.redo()); + match &controller.document.blocks[0] { + DocBlock::Paragraph { align, spans } => { + assert_eq!(*align, DocAlign::Center); + assert_eq!(spans[0].text, "b"); + } + other => panic!("expected paragraph"), + } +} + +#[test] +fn controller_single_execute_and_undo_redo() { + let mut controller = LegacyController::with_document(Document::new(vec![DocBlock::Divider])); + assert!( + controller.execute(Command::ReplaceSpans { + block_idx: 0, + spans: vec![] + }) == false + || controller.document.blocks.len() == 1 + ); + // Divider has no spans to replace: the command reports no-op/failure + // instead of writing junk; document stays intact either way. + assert!(matches!(controller.document.blocks[0], DocBlock::Divider)); +} + +// == advanced_json: versioned persistence for the v2 node model ============ + +use super::advanced_json::{deserialize_advanced_nodes, serialize_advanced_nodes}; + +fn aj_span(text: &str) -> StyleSpan { + StyleSpan { + text: text.into(), + ..Default::default() + } +} + +fn aj_text(text: &str) -> Inline { + Inline::Text(aj_span(text)) +} + +fn aj_node(id: u64, kind: BlockKind) -> DocumentNode { + DocumentNode { + id, + style: BlockStyle::default(), + kind, + } +} + +fn aj_round_trip(nodes: &[DocumentNode]) -> (String, Vec) { + let json = serialize_advanced_nodes(nodes).expect("serialize"); + let back = deserialize_advanced_nodes(&json).expect("deserialize"); + let rejson = serialize_advanced_nodes(&back).expect("re-serialize"); + assert_eq!( + json, rejson, + "serialize -> deserialize -> serialize must be stable" + ); + (json, back) +} + +#[test] +fn advanced_json_round_trips_textual_blocks() { + let mut red = aj_span("red run"); + red.bold = true; + red.color = Some(makepad_widgets::vec4(1.0, 0.25, 0.5, 1.0)); + let nodes = vec![ + aj_node( + 1, + BlockKind::Paragraph { + inlines: vec![ + Inline::Text(red), + Inline::SoftBreak, + Inline::HardBreak, + Inline::Tag("todo".into()), + Inline::Mention { + id: "user-1".into(), + label: "Ada".into(), + }, + Inline::Equation("x = 1".into()), + ], + }, + ), + aj_node( + 2, + BlockKind::Heading { + level: 3, + inlines: vec![aj_text("title")], + }, + ), + aj_node( + 3, + BlockKind::List { + ordered: true, + start: 4, + items: vec![ListItem { + children: vec![aj_node( + 10, + BlockKind::Paragraph { + inlines: vec![aj_text("item")], + }, + )], + }], + }, + ), + aj_node( + 4, + BlockKind::Quote { + children: vec![aj_node( + 11, + BlockKind::Paragraph { + inlines: vec![aj_text("quoted")], + }, + )], + }, + ), + aj_node( + 5, + BlockKind::Code { + language: Some("rust".into()), + source: "fn main() {}".into(), + }, + ), + aj_node(6, BlockKind::Divider), + aj_node(7, BlockKind::PageBreak), + aj_node( + 8, + BlockKind::Columns { + count: 2, + children: vec![aj_node( + 12, + BlockKind::Paragraph { + inlines: vec![aj_text("column")], + }, + )], + }, + ), + aj_node( + 9, + BlockKind::Container { + children: vec![aj_node( + 13, + BlockKind::Paragraph { + inlines: vec![aj_text("boxed")], + }, + )], + }, + ), + ]; + let (_json, back) = aj_round_trip(&nodes); + assert_eq!(back.len(), 9); + match &back[0].kind { + BlockKind::Paragraph { inlines } => { + assert_eq!(inlines.len(), 6); + match &inlines[0] { + Inline::Text(span) => { + assert!(span.bold); + assert_eq!(span.text, "red run"); + assert!( + span.color.is_some(), + "span color must survive the round trip" + ); + } + other => panic!("expected text inline, got {other:?}"), + } + assert!(matches!(inlines[1], Inline::SoftBreak)); + assert!(matches!(inlines[2], Inline::HardBreak)); + } + other => panic!("expected paragraph, got {other:?}"), + } + match &back[2].kind { + BlockKind::List { + ordered, + start, + items, + } => { + assert!(*ordered && *start == 4 && items.len() == 1); + } + other => panic!("expected list, got {other:?}"), + } + match &back[4].kind { + BlockKind::Code { language, source } => { + assert_eq!(language.as_deref(), Some("rust")); + assert_eq!(source, "fn main() {}"); + } + other => panic!("expected code, got {other:?}"), + } + assert!(matches!(back[5].kind, BlockKind::Divider)); + assert!(matches!(back[6].kind, BlockKind::PageBreak)); +} + +#[test] +fn advanced_json_round_trips_tables_with_spans_and_cell_style() { + let nodes = vec![aj_node( + 1, + BlockKind::Table(TableModel { + columns: vec![ + TableColumn { width: Some(120.0) }, + TableColumn { width: None }, + ], + rows: vec![ + TableRow { + height: Some(28.0), + cells: vec![ + TableCellModel { + row_span: 2, + col_span: 1, + children: vec![aj_node( + 20, + BlockKind::Paragraph { + inlines: vec![aj_text("cell")], + }, + )], + style: CellStyle { + background: Some((0.1, 0.2, 0.3, 1.0)), + }, + }, + TableCellModel::default(), + ], + }, + TableRow::default(), + ], + borders: TableBorders { width: 2.5 }, + }), + )]; + let (_json, back) = aj_round_trip(&nodes); + match &back[0].kind { + BlockKind::Table(table) => { + assert_eq!(table.columns[0].width, Some(120.0)); + assert_eq!(table.columns[1].width, None); + assert_eq!(table.borders.width, 2.5); + assert_eq!(table.rows[0].height, Some(28.0)); + assert_eq!(table.rows[0].cells[0].row_span, 2); + assert_eq!( + table.rows[0].cells[0].style.background, + Some((0.1, 0.2, 0.3, 1.0)) + ); + assert_eq!(table.rows[0].cells[0].children.len(), 1); + } + other => panic!("expected table, got {other:?}"), + } +} + +#[test] +fn advanced_json_round_trips_media_and_embedded_nodes() { + let nodes = vec![ + aj_node( + 1, + BlockKind::Image(ImageNode { + resource: "img.png".into(), + size: dvec2(320.0, 200.0), + caption: vec![aj_text("caption")], + }), + ), + aj_node( + 2, + BlockKind::Audio { + resource: "a.mp3".into(), + }, + ), + aj_node( + 3, + BlockKind::Video { + resource: "v.mp4".into(), + }, + ), + aj_node( + 4, + BlockKind::Diagram { + resource: "d.svg".into(), + }, + ), + aj_node( + 5, + BlockKind::EmbeddedWidget(EmbeddedWidgetNode { + widget_type: "chart".into(), + state_json: "{}".into(), + preferred_size: Some(dvec2(200.0, 100.0)), + }), + ), + ]; + let (_json, back) = aj_round_trip(&nodes); + match &back[0].kind { + BlockKind::Image(image) => { + assert_eq!(image.resource, "img.png"); + assert_eq!(image.size, dvec2(320.0, 200.0)); + assert_eq!(image.caption.len(), 1); + } + other => panic!("expected image, got {other:?}"), + } + match &back[1].kind { + BlockKind::Audio { resource } => assert_eq!(resource, "a.mp3"), + other => panic!("expected audio, got {other:?}"), + } + match &back[2].kind { + BlockKind::Video { resource } => assert_eq!(resource, "v.mp4"), + other => panic!("expected video, got {other:?}"), + } + match &back[3].kind { + BlockKind::Diagram { resource } => assert_eq!(resource, "d.svg"), + other => panic!("expected diagram, got {other:?}"), + } + match &back[4].kind { + BlockKind::EmbeddedWidget(widget) => { + assert_eq!(widget.widget_type, "chart"); + assert_eq!(widget.preferred_size, Some(dvec2(200.0, 100.0))); + } + other => panic!("expected embedded widget, got {other:?}"), + } +} + +#[test] +fn advanced_json_round_trips_link_image_and_widget_inlines() { + let nodes = vec![aj_node( + 1, + BlockKind::Paragraph { + inlines: vec![ + Inline::Link { + target: "https://example.com".into(), + children: vec![aj_text("link text")], + }, + Inline::Image { + resource: "inline.png".into(), + size: dvec2(12.0, 12.0), + }, + Inline::InlineWidget(EmbeddedWidgetNode { + widget_type: "star".into(), + state_json: "{}".into(), + preferred_size: None, + }), + ], + }, + )]; + let (_json, back) = aj_round_trip(&nodes); + match &back[0].kind { + BlockKind::Paragraph { inlines } => { + assert_eq!(inlines.len(), 3); + match &inlines[0] { + Inline::Link { target, children } => { + assert_eq!(target, "https://example.com"); + assert_eq!(children.len(), 1); + } + other => panic!("expected link, got {other:?}"), + } + match &inlines[1] { + Inline::Image { resource, size } => { + assert_eq!(resource, "inline.png"); + assert_eq!(*size, dvec2(12.0, 12.0)); + } + other => panic!("expected inline image, got {other:?}"), + } + match &inlines[2] { + Inline::InlineWidget(widget) => assert_eq!(widget.widget_type, "star"), + other => panic!("expected inline widget, got {other:?}"), + } + } + other => panic!("expected paragraph, got {other:?}"), + } +} + +#[test] +fn advanced_json_round_trips_canvas_objects() { + let nodes = vec![aj_node( + 1, + BlockKind::Canvas(CanvasNode { + size: dvec2(400.0, 240.0), + objects: vec![ + CanvasObject::Rectangle { + position: dvec2(0.0, 0.0), + size: dvec2(10.0, 10.0), + }, + CanvasObject::Ellipse { + position: dvec2(5.0, 5.0), + size: dvec2(8.0, 8.0), + }, + CanvasObject::Arrow { + from: dvec2(0.0, 0.0), + to: dvec2(20.0, 20.0), + }, + CanvasObject::Image { + position: dvec2(1.0, 1.0), + resource: "canvas.png".into(), + size: dvec2(16.0, 16.0), + }, + CanvasObject::EmbeddedWidget(EmbeddedWidgetNode { + widget_type: "knob".into(), + state_json: "{}".into(), + preferred_size: None, + }), + ], + }), + )]; + let (_json, back) = aj_round_trip(&nodes); + match &back[0].kind { + BlockKind::Canvas(canvas) => { + assert_eq!(canvas.size, dvec2(400.0, 240.0)); + assert_eq!(canvas.objects.len(), 5); + } + other => panic!("expected canvas, got {other:?}"), + } +} + +#[test] +fn advanced_json_rejects_canvas_text_until_persistence_lands() { + let nodes = vec![aj_node( + 1, + BlockKind::Canvas(CanvasNode { + size: dvec2(10.0, 10.0), + objects: vec![CanvasObject::Text { + position: dvec2(0.0, 0.0), + inlines: vec![aj_text("draft")], + }], + }), + )]; + let error = serialize_advanced_nodes(&nodes).expect_err("canvas text is not persistable yet"); + assert!( + error.contains("canvas text persistence not implemented"), + "unexpected error: {error}" + ); +} + +#[test] +fn advanced_json_rejects_unknown_schema_version() { + let error = deserialize_advanced_nodes(r#"{"version": 2, "nodes": []}"#) + .expect_err("only version 3 is supported"); + assert!( + error.contains("unsupported advanced document version 2"), + "unexpected error: {error}" + ); +} + +#[test] +fn advanced_json_rejects_malformed_payloads() { + assert!(deserialize_advanced_nodes("{ not json").is_err()); + // A structurally valid payload with a shape serde does not recognize. + assert!(deserialize_advanced_nodes(r#"{"version": 3, "nodes": "oops"}"#).is_err()); +} + +#[test] +fn advanced_json_empty_document_round_trips_with_version_stamp() { + let json = serialize_advanced_nodes(&[]).expect("serialize empty"); + assert!( + json.contains("\"version\": 3"), + "version stamp missing: {json}" + ); + let back = deserialize_advanced_nodes(&json).expect("deserialize empty"); + assert!(back.is_empty()); +} + +// == layout: pure geometry, caches, and the renderer-independent tree ====== + +fn measure_eight(_span: &StyleSpan, text: &str) -> f64 { + text.chars().count() as f64 * 8.0 +} + +#[test] +fn block_layout_wraps_words_and_reports_every_glyph() { + let spans = vec![StyleSpan { + text: "aa bb".into(), + font_size: 10.0, + ..Default::default() + }]; + let fragment = layout_paragraph( + ParagraphLayoutRequest { + block_idx: 0, + spans: &spans, + origin: dvec2(0.0, 0.0), + available_width: 25.0, + align: DocAlign::Left, + default_font_size: 10.0, + line_spacing: 1.0, + }, + |_span, text| text.chars().count() as f64 * 10.0, + ); + assert_eq!(fragment.lines.len(), 2, "the second word must wrap"); + assert_eq!(fragment.lines[0].runs[0].text, "aa "); + assert_eq!(fragment.lines[1].runs[0].text, "bb"); + // Every character gets a hit-test rect, including whitespace. + assert_eq!(fragment.glyph_hits.len(), 5); + assert_eq!(fragment.glyph_hits[0].char_offset, 0); + assert_eq!(fragment.glyph_hits[4].char_offset, 4); + assert!( + (fragment.height - 20.0).abs() < 1e-9, + "two 10pt lines: {}", + fragment.height + ); +} + +#[test] +fn block_layout_empty_span_emits_a_zero_width_caret_glyph_only() { + let spans = vec![StyleSpan { + text: String::new(), + font_size: 12.0, + ..Default::default() + }]; + let fragment = layout_paragraph( + ParagraphLayoutRequest { + block_idx: 3, + spans: &spans, + origin: dvec2(7.0, 9.0), + available_width: 200.0, + align: DocAlign::Left, + default_font_size: 12.0, + line_spacing: 1.0, + }, + measure_eight, + ); + assert!(fragment.lines.is_empty()); + assert_eq!(fragment.glyph_hits.len(), 1); + let hit = &fragment.glyph_hits[0]; + assert_eq!(hit.block_idx, 3); + assert_eq!(hit.char_offset, 0); + assert_eq!( + hit.rect.size.x, 1.0, + "caret-width glyph for empty paragraphs" + ); + assert_eq!(fragment.height, 0.0); +} + +#[test] +fn block_layout_center_and_right_alignment_offset_runs() { + let spans = vec![StyleSpan { + text: "ab".into(), + font_size: 10.0, + ..Default::default() + }]; + for (align, expected_x) in [(DocAlign::Center, 40.0), (DocAlign::Right, 80.0)] { + let fragment = layout_paragraph( + ParagraphLayoutRequest { + block_idx: 0, + spans: &spans, + origin: dvec2(0.0, 0.0), + available_width: 100.0, + align: align.clone(), + default_font_size: 10.0, + line_spacing: 1.0, + }, + |_span, text| text.chars().count() as f64 * 10.0, + ); + assert_eq!(fragment.lines.len(), 1); + assert!( + (fragment.lines[0].rect.pos.x - expected_x).abs() < 1e-9, + "{align:?} must offset to {expected_x}, got {}", + fragment.lines[0].rect.pos.x + ); + assert!((fragment.lines[0].runs[0].rect.pos.x - expected_x).abs() < 1e-9); + } +} + +#[test] +fn table_layout_falls_back_to_even_columns_and_handles_zero_columns() { + let fragment = layout_table(TableLayoutRequest { + origin: dvec2(10.0, 20.0), + rows: 2, + cols: 3, + col_widths: &[100.0], + available_width: 300.0, + row_height: 40.0, + }); + assert_eq!(fragment.cells.len(), 6); + // Only the first column supplies a width; the rest even-split the space. + let second = fragment + .cells + .iter() + .find(|cell| (cell.row, cell.col) == (0, 1)) + .unwrap(); + assert_eq!(second.rect.pos, dvec2(110.0, 20.0)); + assert_eq!(second.rect.size, dvec2(100.0, 40.0)); + assert_eq!(fragment.rect.unwrap().size, dvec2(300.0, 80.0)); + assert_eq!(fragment.height, 80.0); + + let empty = layout_table(TableLayoutRequest { + origin: dvec2(0.0, 0.0), + rows: 1, + cols: 0, + col_widths: &[], + available_width: 250.0, + row_height: 30.0, + }); + assert!(empty.cells.is_empty()); + assert_eq!(empty.height, 30.0); + assert_eq!(empty.rect.unwrap().size, dvec2(0.0, 30.0)); +} + +#[test] +fn table_render_cells_skip_covered_cells_and_expand_the_anchor() { + let fragment = layout_table(TableLayoutRequest { + origin: dvec2(0.0, 0.0), + rows: 2, + cols: 2, + col_widths: &[100.0, 100.0], + available_width: 200.0, + row_height: 40.0, + }); + let cells = vec![ + vec![ + CellContent { text: "a".into() }, + CellContent { text: "b".into() }, + ], + vec![ + CellContent { text: "c".into() }, + CellContent { text: "d".into() }, + ], + ]; + let merges = vec![TableMerge { + selection: TableSelection { + block_idx: 0, + start_row: 0, + start_col: 0, + end_row: 0, + end_col: 1, + }, + }]; + let render = build_table_render_cells(&fragment, &cells, &merges, 0); + assert_eq!( + render.len(), + 3, + "the covered cell (0,1) is absorbed into its anchor" + ); + let anchor = render + .iter() + .find(|cell| (cell.row, cell.col) == (0, 0)) + .unwrap(); + assert_eq!( + anchor.rect.size, + dvec2(200.0, 40.0), + "anchor spans both columns" + ); + assert_eq!(anchor.text, "a"); + assert!(anchor.is_header); + assert!(render.iter().all(|cell| (cell.row, cell.col) != (0, 1))); + let body = render + .iter() + .find(|cell| (cell.row, cell.col) == (1, 1)) + .unwrap(); + assert!(!body.is_header); + assert_eq!(body.text, "d"); +} + +#[test] +fn page_layout_stacks_overflow_and_always_returns_one_page() { + let pages = layout_pages(dvec2(50.0, 60.0), 500.0, 700.0, 24.0, 1600.0); + assert_eq!(pages.len(), 3); + assert_eq!(pages[0].pos, dvec2(50.0, 60.0)); + assert_eq!(pages[1].pos, dvec2(50.0, 784.0)); + assert_eq!(pages[2].pos, dvec2(50.0, 1508.0)); + assert!(pages.iter().all(|page| page.size == dvec2(500.0, 700.0))); + + let empty = layout_pages(dvec2(0.0, 0.0), 500.0, 700.0, 24.0, 0.0); + assert_eq!(empty.len(), 1, "an empty document still needs a first page"); +} + +#[test] +fn divider_and_image_layout_clamp_and_align() { + let divider = layout_divider(dvec2(5.0, 6.0), 0.0, 0.0); + assert_eq!( + divider.size, + dvec2(1.0, 1.0), + "zero sizes clamp to a visible sliver" + ); + + let origin = dvec2(10.0, 20.0); + assert_eq!( + layout_image(origin, 400.0, 200.0, 100.0, &DocAlign::Left) + .pos + .x, + 10.0 + ); + assert_eq!( + layout_image(origin, 400.0, 200.0, 100.0, &DocAlign::Center) + .pos + .x, + 110.0 + ); + assert_eq!( + layout_image(origin, 400.0, 200.0, 100.0, &DocAlign::Right) + .pos + .x, + 210.0 + ); + let clamped = layout_image(origin, 400.0, 800.0, 0.0, &DocAlign::Right); + assert_eq!( + clamped.size, + dvec2(400.0, 1.0), + "width clamps to available, height to 1" + ); + assert_eq!(clamped.pos.x, 10.0, "a full-width image cannot shift right"); +} + +#[test] +fn glyph_hit_reports_cursor_and_point_membership() { + let hit = GlyphHit { + block_idx: 2, + cell_pos: Some((1, 0)), + span_idx: 1, + char_offset: 3, + rect: makepad_widgets::Rect { + pos: dvec2(10.0, 10.0), + size: dvec2(7.0, 18.0), + }, + }; + assert_eq!( + hit.cursor(), + DocCursor { + block_idx: 2, + cell_pos: Some((1, 0)), + span_idx: 1, + char_offset: 3, + } + ); + assert!(hit.contains(dvec2(12.0, 15.0))); + assert!(!hit.contains(dvec2(5.0, 5.0))); +} + +#[test] +fn block_layout_cache_keys_on_revision_and_geometry() { + let fragment = |revision, height| BlockLayoutFragment { + block_idx: 0, + revision, + rect: makepad_widgets::Rect { + pos: dvec2(0.0, 0.0), + size: dvec2(100.0, height), + }, + first_page: 0, + last_page: 0, + content: None, + }; + let mut cache = BlockLayoutCache::default(); + cache.put(fragment(1, 20.0)); + assert!(cache.get(0, 1).is_some()); + assert!( + cache.get(0, 2).is_none(), + "stale revisions must not be served" + ); + assert!(cache.get_at(0, 1, dvec2(0.0, 0.0), 100.0).is_some()); + assert!( + cache.get_at(0, 1, dvec2(5.0, 0.0), 100.0).is_none(), + "moved blocks reflow" + ); + + assert!( + !cache.put_detect_height_change(fragment(2, 20.0)), + "same height: no reflow ripple" + ); + assert!( + cache.put_detect_height_change(fragment(3, 42.0)), + "taller block reflows followers" + ); + assert!( + !cache.put_detect_height_change(BlockLayoutFragment { + block_idx: 4, + ..fragment(1, 9.0) + }), + "first insert has nothing to compare against" + ); + assert_eq!(cache.len(), 2); + cache.invalidate_block(4); + assert_eq!(cache.len(), 1); + cache.invalidate_from(0); + assert_eq!(cache.len(), 0); +} + +#[test] +fn layout_engine_tracks_dirty_blocks_then_clears_on_layout() { + let mut engine = LayoutEngine::default(); + assert!(!engine.is_dirty()); + assert!(!engine.requires_full_reflow()); + assert_eq!(engine.dirty_blocks().count(), 0); + + engine.invalidate_block(2); + assert!(engine.is_dirty()); + assert_eq!(engine.dirty_blocks().collect::>(), vec![2]); + + engine.invalidate_range(3, 1); + assert_eq!(engine.dirty_blocks().collect::>(), vec![1, 2, 3]); + + engine.invalidate(); + assert!(engine.requires_full_reflow()); + assert_eq!( + engine.dirty_blocks().count(), + 0, + "full reflow tracks no subset" + ); + + let key = TextMeasureKey { + text: "measure me".into(), + font_size_bits: 12.0f32.to_bits(), + bold: false, + italic: false, + }; + assert_eq!(engine.cached_text_width(&key), None); + engine.store_text_width(key.clone(), 96.5); + assert_eq!(engine.cached_text_width(&key), Some(96.5)); + + engine.begin_layout(7); + assert!(!engine.is_dirty()); + assert!(!engine.requires_full_reflow()); + assert_eq!(engine.tree.revision, 7); +} + +#[test] +fn layout_tree_collects_fragments_and_answers_hit_caret_and_selection_queries() { + let mut tree = LayoutTree::default(); + tree.clear_for_revision(9); + assert_eq!(tree.revision, 9); + + tree.push_divider(0, layout_divider(dvec2(0.0, 0.0), 100.0, 2.0)); + tree.push_image( + 1, + layout_image(dvec2(0.0, 10.0), 200.0, 100.0, 50.0, &DocAlign::Left), + ); + let table = layout_table(TableLayoutRequest { + origin: dvec2(0.0, 70.0), + rows: 2, + cols: 2, + col_widths: &[50.0, 50.0], + available_width: 100.0, + row_height: 20.0, + }); + tree.push_table_fragment(2, &table); + let spans = vec![StyleSpan { + text: "ab".into(), + font_size: 10.0, + ..Default::default() + }]; + let paragraph = layout_paragraph( + ParagraphLayoutRequest { + block_idx: 3, + spans: &spans, + origin: dvec2(0.0, 120.0), + available_width: 200.0, + align: DocAlign::Left, + default_font_size: 10.0, + line_spacing: 1.0, + }, + measure_eight, + ); + tree.push_paragraph_fragment(3, ¶graph); + + assert_eq!(tree.dividers.len(), 1); + assert_eq!(tree.images.len(), 1); + assert_eq!(tree.table_cells.len(), 4); + assert_eq!(tree.lines.len(), 1); + assert_eq!(tree.runs.len(), 1); + assert_eq!(tree.glyph_hits.len(), 2); + + let first = tree.glyph_hits[0].cursor(); + let second = tree.glyph_hits[1].cursor(); + assert_eq!(first.char_offset, 0); + assert_eq!(second.char_offset, 1); + + // Hit and nearest queries resolve against stored glyph geometry. + let on_glyph = tree + .hit_test(dvec2(1.0, 125.0)) + .expect("inside first glyph"); + assert_eq!(on_glyph.cursor(), first); + let nearest = tree + .nearest_hit(dvec2(1000.0, 1000.0)) + .expect("never empty"); + assert_eq!(nearest.cursor(), second); + + // Selections normalize direction and cover (anchor, focus). + assert_eq!(tree.selection_rects(first, second).len(), 1); + assert_eq!( + tree.selection_rects(second, first).len(), + 1, + "reversed drags select the same range" + ); + let end = DocCursor { + char_offset: 2, + ..second + }; + assert_eq!(tree.selection_rects(first, end).len(), 2); + + // Caret: exact glyph match draws at the glyph's left edge; past-the-end + // cursors snap to the right edge of the preceding glyph; unknown blocks + // have no caret at all. + assert_eq!(tree.caret_rect(first).unwrap().pos.x, 0.0); + assert_eq!(tree.caret_rect(end).unwrap().pos.x, 16.0); + assert!(tree + .caret_rect(DocCursor { + block_idx: 99, + ..end + }) + .is_none()); + + tree.clear_for_revision(10); + assert_eq!(tree.revision, 10); + assert!(tree.glyph_hits.is_empty() && tree.lines.is_empty() && tree.table_cells.is_empty()); +} + +#[test] +fn advanced_layout_stacks_every_block_kind_with_labels_and_interactivity() { + fn plain(id: u64, kind: BlockKind) -> DocumentNode { + aj_node(id, kind) + } + let mut styled_heading = aj_node( + 1, + BlockKind::Heading { + level: 2, + inlines: vec![], + }, + ); + styled_heading.style.margin_before = 4.0; + styled_heading.style.margin_after = 6.0; + let nodes = vec![ + plain(0, BlockKind::Paragraph { inlines: vec![] }), + styled_heading, + plain( + 2, + BlockKind::List { + ordered: false, + start: 1, + items: vec![ + ListItem::default(), + ListItem::default(), + ListItem::default(), + ], + }, + ), + plain(3, BlockKind::Quote { children: vec![] }), + plain( + 4, + BlockKind::Code { + language: Some("rust".into()), + source: "let a = 1;\nlet b = 2;".into(), + }, + ), + plain( + 5, + BlockKind::Table(TableModel { + rows: vec![TableRow::default(), TableRow::default()], + ..Default::default() + }), + ), + plain( + 6, + BlockKind::Image(ImageNode { + resource: "r".into(), + size: dvec2(10.0, 30.0), + caption: vec![], + }), + ), + plain(7, BlockKind::Divider), + plain(8, BlockKind::PageBreak), + plain( + 9, + BlockKind::Columns { + count: 3, + children: vec![], + }, + ), + plain(10, BlockKind::Container { children: vec![] }), + plain( + 11, + BlockKind::Canvas(CanvasNode { + size: dvec2(0.0, 0.0), + objects: vec![], + }), + ), + plain( + 12, + BlockKind::EmbeddedWidget(EmbeddedWidgetNode { + widget_type: "clock".into(), + state_json: "{}".into(), + preferred_size: Some(dvec2(10.0, 90.0)), + }), + ), + plain( + 13, + BlockKind::Audio { + resource: "a".into(), + }, + ), + plain( + 14, + BlockKind::Video { + resource: "v".into(), + }, + ), + plain( + 15, + BlockKind::Diagram { + resource: "d".into(), + }, + ), + ]; + let blocks = AdvancedLayout::layout(&nodes, 16.0, 0.0, 480.0); + assert_eq!(blocks.len(), 16); + assert_eq!(blocks[0].rect.pos, dvec2(16.0, 0.0)); + assert_eq!(blocks[0].rect.size.y, 28.0); + assert!(!blocks[0].interactive); + assert_eq!(blocks[1].label, "Heading 2"); + assert_eq!( + blocks[1].rect.pos.y, 32.0, + "margin_before shifts the rect down" + ); + assert_eq!( + blocks[2].rect.pos.y, 74.0, + "margin_after pushes followers down" + ); + assert_eq!(blocks[2].rect.size.y, 84.0, "three list items at 28 each"); + assert_eq!(blocks[4].label, "Code rust"); + assert_eq!(blocks[5].rect.size.y, 72.0); + assert!(blocks[5].interactive, "tables are interactive"); + assert_eq!(blocks[6].rect.size.y, 48.0, "images keep a minimum height"); + assert_eq!(blocks[9].label, "3 Columns"); + assert_eq!( + blocks[11].rect.size.y, 120.0, + "canvases keep a minimum height" + ); + assert_eq!(blocks[12].label, "Widget: clock"); + assert_eq!( + blocks[12].rect.size.y, 90.0, + "preferred size wins when present" + ); + assert_eq!(blocks[12].node_index, 12); + // Geometry is a strict vertical stack: every block starts after the last. + for pair in blocks.windows(2) { + assert!(pair[1].rect.pos.y >= pair[0].rect.pos.y + pair[0].rect.size.y); + } +} + +// == projection layout: hit-test snap/exclusion and handle edge cases ======= + +#[test] +fn projection_hit_test_snaps_to_nearest_glyph_and_hits_exact_rects() { + let engine = projection_word_engine(); + let tree = layout_projection(&engine.projection); + let glyphs: Vec<_> = tree.glyphs.iter().collect(); + assert!(glyphs.len() > 2); + let first = glyphs[0]; + let last = glyphs[glyphs.len() - 1]; + + // Points inside a glyph rect resolve exactly. + let inside = dvec2(first.rect.pos.x + 1.0, first.rect.pos.y + 1.0); + assert_eq!( + tree.hit_test(inside).map(|hit| hit.atom.clone()), + Some(first.atom.clone()) + ); + + // Empty space on the same line snaps to the nearest glyph. + let far_right = dvec2(last.rect.pos.x + 500.0, last.rect.pos.y + 2.0); + let snapped = tree.hit_test(far_right).expect("nearest-glyph snap"); + assert_eq!(snapped.atom, last.atom); +} + +#[test] +fn projection_hit_test_never_steals_taps_from_tables_or_nodes() { + let mut engine = CrdtController::default(); + let para = engine.insert_block("t", None, "paragraph").unwrap(); + engine.insert_text("t", para.clone(), None, "text above the table"); + let table = engine + .insert_block("t", Some(para.clone()), "table") + .unwrap(); + let row0 = engine.insert_table_row("t", table.clone(), None).unwrap(); + engine + .insert_table_row("t", table.clone(), Some(row0.clone())) + .unwrap(); + let col0 = engine + .insert_table_column("t", table.clone(), None) + .unwrap(); + engine + .insert_table_column("t", table.clone(), Some(col0.clone())) + .unwrap(); + engine.insert_node("t", None, None, "canvas", "{}"); + let tree = layout_projection(&engine.projection); + assert!( + !tree.glyphs.is_empty(), + "the paragraph must contribute glyphs" + ); + + let table_rect = tree.tables[0].rect; + let table_center = dvec2( + table_rect.pos.x + table_rect.size.x * 0.5, + table_rect.pos.y + table_rect.size.y * 0.5, + ); + assert!( + tree.hit_test(table_center).is_none(), + "table bodies belong to table_hit_test, not glyph snap" + ); + + let node_rect = tree.nodes[0].rect; + let node_center = dvec2( + node_rect.pos.x + node_rect.size.x * 0.5, + node_rect.pos.y + node_rect.size.y * 0.5, + ); + assert!( + tree.hit_test(node_center).is_none(), + "node bodies belong to node_hit_test, not glyph snap" + ); +} + +#[test] +fn selection_handles_require_two_known_distinct_glyphs() { + let engine = projection_word_engine(); + let tree = layout_projection(&engine.projection); + let atoms = &engine.projection.blocks[0].runs[0].atoms; + let start = atoms[4].clone(); + let end = atoms[8].clone(); + assert!(selection_handles(&tree, &start, &end).is_some()); + assert!( + selection_handles(&tree, &start, &start).is_none(), + "a collapsed selection has a caret, never handles" + ); + let bogus = OpId { + actor: "nobody".into(), + counter: 999, + }; + assert!( + selection_handles(&tree, &bogus, &end).is_none(), + "unknown anchor atom" + ); + assert!( + selection_handles(&tree, &start, &bogus).is_none(), + "unknown focus atom" + ); +} + +// == crdt bridge: node-kind mapping and table merge projection ============== + +#[test] +fn crdt_bridge_maps_every_node_kind_and_unknown_kinds_to_embedded() { + let mut engine = CrdtController::default(); + let mut after = None; + for kind in [ + "canvas", + "divider", + "audio", + "video", + "diagram", + "image", + "sparkline", + ] { + after = engine.insert_node("a", None, after, kind, "state-json"); + } + let mut document = Document::default(); + CrdtProjectionBridge::apply(&engine.projection, &mut document); + assert_eq!(document.nodes.len(), 7); + assert!( + document + .blocks + .iter() + .all(|block| matches!(block, DocBlock::AdvancedNodeRef { .. })), + "every node projects as an inline AdvancedNodeRef block" + ); + assert!(matches!(document.nodes[0].kind, BlockKind::Canvas(_))); + assert!(matches!(document.nodes[1].kind, BlockKind::Divider)); + match &document.nodes[2].kind { + BlockKind::Audio { resource } => assert_eq!(resource, "state-json"), + other => panic!("expected audio, got {other:?}"), + } + match &document.nodes[3].kind { + BlockKind::Video { resource } => assert_eq!(resource, "state-json"), + other => panic!("expected video, got {other:?}"), + } + match &document.nodes[4].kind { + BlockKind::Diagram { resource } => assert_eq!(resource, "state-json"), + other => panic!("expected diagram, got {other:?}"), + } + match &document.nodes[5].kind { + BlockKind::Image(image) => assert_eq!(image.resource, "state-json"), + other => panic!("expected image, got {other:?}"), + } + match &document.nodes[6].kind { + BlockKind::EmbeddedWidget(widget) => { + assert_eq!( + widget.widget_type, "sparkline", + "unknown kinds keep their tag" + ); + assert_eq!(widget.state_json, "state-json"); + } + other => panic!("expected embedded widget fallback, got {other:?}"), + } + assert!( + document + .nodes + .iter() + .enumerate() + .all(|(index, node)| node.id == index as u64 + 1), + "node ids are assigned by projection order" + ); +} + +#[test] +fn crdt_bridge_materializes_normalized_table_merges() { + let (mut engine, table, row0, _row1, col0, col1) = projection_table_engine(); + engine.merge_table_cells("t", table, row0.clone(), col0, row0, col1); + let mut document = Document::default(); + CrdtProjectionBridge::apply(&engine.projection, &mut document); + assert_eq!(document.table_merges.len(), 1); + let selection = &document.table_merges[0].selection; + assert_eq!(selection.block_idx, 0, "the table is the first block"); + assert_eq!(selection.start_row, 0); + assert_eq!(selection.start_col, 0); + assert_eq!(selection.end_row, 0); + assert_eq!(selection.end_col, 1); +} + +// == editing commands: every apply arm mutates and yields its exact inverse = + +fn para_doc(text: &str) -> Document { + Document::new(vec![DocBlock::Paragraph { + align: DocAlign::Left, + spans: vec![StyleSpan { + text: text.into(), + ..Default::default() + }], + }]) +} + +fn table_doc() -> Document { + Document::new(vec![DocBlock::Table { + rows: 2, + cols: 2, + col_widths: vec![100.0, 100.0], + cells: vec![ + vec![ + CellContent { text: "a".into() }, + CellContent { text: "b".into() }, + ], + vec![ + CellContent { text: "c".into() }, + CellContent { text: "d".into() }, + ], + ], + }]) +} + +fn cursor(block: usize, offset: usize) -> DocCursor { + DocCursor { + block_idx: block, + cell_pos: None, + span_idx: 0, + char_offset: offset, + } +} + +#[test] +fn command_insert_text_moves_the_cursor_and_delete_verifies_content() { + let mut document = para_doc("ab"); + let mut session = DocumentSession::default(); + let inverse = Command::InsertText { + at: cursor(0, 1), + text: "X".into(), + } + .apply(&mut document, &mut session) + .expect("insert applies"); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "aXb"), + other => panic!("expected paragraph, got {other:?}"), + } + assert_eq!(session.cursor.unwrap().char_offset, 2); + assert!(matches!(inverse, Command::DeleteText { .. })); + // Applying the inverse restores the original text. + inverse + .apply(&mut document, &mut session) + .expect("inverse applies"); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "ab"), + other => panic!("expected paragraph, got {other:?}"), + } + assert_eq!(session.cursor.unwrap().char_offset, 1); + + // Delete refuses when the recorded text is no longer there. + assert!(Command::DeleteText { + at: cursor(0, 0), + text: "zz".into(), + } + .apply(&mut document, &mut session) + .is_none()); + // Table cells and table blocks are edited through cell commands instead. + assert!(Command::InsertText { + at: DocCursor { + cell_pos: Some((0, 0)), + ..cursor(0, 0) + }, + text: "X".into(), + } + .apply(&mut document, &mut session) + .is_none()); + assert!(Command::InsertText { + at: cursor(5, 0), + text: "X".into(), + } + .apply(&mut document, &mut session) + .is_none()); + // Unicode-safe insertion: offsets are characters, not bytes. + let mut document = para_doc("héllo"); + assert!(Command::InsertText { + at: cursor(0, 5), + text: "!".into(), + } + .apply(&mut document, &mut session) + .is_some()); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "héllo!"), + other => panic!("expected paragraph, got {other:?}"), + } +} + +#[test] +fn command_atom_inserts_are_idempotent_and_delete_resurrects() { + let mut document = para_doc(""); + let mut session = DocumentSession::default(); + let atom = TextAtom { + id: AtomId { + actor: "peer".into(), + sequence: 7, + }, + after: None, + ch: 'Z', + deleted: false, + deleted_at: None, + timestamp: LamportTimestamp { counter: 1 }, + }; + let insert = Command::InsertAtoms { + block_idx: 0, + span_idx: 0, + atoms: vec![atom.clone()], + }; + let inverse = insert.clone().apply(&mut document, &mut session).unwrap(); + assert!(matches!(inverse, Command::DeleteAtoms { ref ids, .. } if ids.len() == 1)); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "Z"), + other => panic!("expected paragraph, got {other:?}"), + } + // Re-inserting the same atom resurrects it without duplicating text. + let replay = insert.apply(&mut document, &mut session).unwrap(); + assert!(matches!(replay, Command::DeleteAtoms { ref ids, .. } if ids.len() == 1)); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "Z"), + other => panic!("expected paragraph, got {other:?}"), + } + // Deleting unknown ids still succeeds (a no-op tombstone) and inverts. + let clock_before = document.crdt.clock.counter; + let inverse = Command::DeleteAtoms { + block_idx: 0, + span_idx: 0, + ids: vec![atom.id.clone()], + } + .apply(&mut document, &mut session) + .expect("tombstone applies"); + assert!(matches!(inverse, Command::InsertAtoms { .. })); + assert!( + document.crdt.clock.counter > clock_before, + "tombstones consume clock" + ); + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, ""), + other => panic!("expected paragraph, got {other:?}"), + } + // Wrong block kinds reject atom edits outright. + let mut tables = table_doc(); + assert!(Command::InsertAtoms { + block_idx: 0, + span_idx: 0, + atoms: vec![atom], + } + .apply(&mut tables, &mut session) + .is_none()); +} + +#[test] +fn command_block_insert_after_and_delete_by_id_track_stable_positions() { + let mut document = Document::new(vec![para_block("one"), para_block("two")]); + let mut session = DocumentSession::default(); + document.ensure_legacy_block_ids(); + let first_id = document.legacy_block_ids[0].clone(); + let new_id = BlockId(AtomId { + actor: "me".into(), + sequence: 42, + }); + let inverse = Command::InsertBlockAfter { + after: Some(first_id.clone()), + block_id: new_id.clone(), + block: para_block("inserted"), + } + .apply(&mut document, &mut session) + .expect("insert applies"); + assert_eq!(document.blocks.len(), 3); + assert_eq!(document.legacy_block_ids[1], new_id); + assert!(matches!(inverse, Command::DeleteBlockById { .. })); + // Round trip restores the exact original pair. + inverse.apply(&mut document, &mut session).unwrap(); + assert_eq!(document.blocks.len(), 2); + assert_eq!(document.legacy_block_ids[0], first_id); + // Inserting after an unknown anchor fails without mutating. + assert!(Command::InsertBlockAfter { + after: Some(BlockId(AtomId { + actor: "ghost".into(), + sequence: 1, + })), + block_id: new_id, + block: para_block("nope"), + } + .apply(&mut document, &mut session) + .is_none()); + assert!(Command::DeleteBlockById { + block_id: BlockId(AtomId { + actor: "ghost".into(), + sequence: 1, + }), + } + .apply(&mut document, &mut session) + .is_none()); +} + +fn para_block(text: &str) -> DocBlock { + DocBlock::Paragraph { + align: DocAlign::Left, + spans: vec![StyleSpan { + text: text.into(), + ..Default::default() + }], + } +} + +#[test] +fn command_set_image_properties_round_trips_and_rejects_non_images() { + let mut document = Document::new(vec![DocBlock::Image { + caption: "old caption".into(), + width: 100.0, + height: 50.0, + align: DocAlign::Left, + }]); + let mut session = DocumentSession::default(); + let inverse = Command::SetImageProperties { + block_idx: 0, + caption: "new caption".into(), + width: 640.0, + height: 480.0, + align: DocAlign::Right, + } + .apply(&mut document, &mut session) + .expect("image update applies"); + match &document.blocks[0] { + DocBlock::Image { + caption, + width, + height, + align, + } => { + assert_eq!(caption, "new caption"); + assert_eq!(*width, 640.0); + assert_eq!(*height, 480.0); + assert_eq!(*align, DocAlign::Right); + } + other => panic!("expected image, got {other:?}"), + } + inverse.apply(&mut document, &mut session).unwrap(); + match &document.blocks[0] { + DocBlock::Image { caption, align, .. } => { + assert_eq!(caption, "old caption"); + assert_eq!(*align, DocAlign::Left); + } + other => panic!("expected image, got {other:?}"), + } + assert!(Command::SetImageProperties { + block_idx: 0, + caption: "x".into(), + width: 1.0, + height: 1.0, + align: DocAlign::Left, + } + .apply(&mut para_doc("text"), &mut session) + .is_none()); +} + +#[test] +fn command_replace_block_range_swaps_and_restores_stable_ids() { + let mut document = Document::new(vec![para_block("p0"), para_block("p1"), para_block("p2")]); + let mut session = DocumentSession::default(); + document.ensure_legacy_block_ids(); + let original_ids = document.legacy_block_ids.clone(); + let inverse = Command::ReplaceBlockRange { + index: 1, + remove_count: 2, + blocks: vec![para_block("joined")], + block_ids: None, + } + .apply(&mut document, &mut session) + .expect("range replace applies"); + assert_eq!(document.blocks.len(), 2); + // The removed ids were tombstoned out of the order CRDT. + assert!(document + .block_order + .visible_ids() + .iter() + .all(|id| !original_ids[1..].contains(id))); + // Undo restores the exact blocks with their original stable ids. + inverse.apply(&mut document, &mut session).unwrap(); + assert_eq!(document.blocks.len(), 3); + assert_eq!(document.legacy_block_ids, original_ids); + // Bounds and id-count mismatches fail without touching the document. + assert!(Command::ReplaceBlockRange { + index: 2, + remove_count: 5, + blocks: vec![], + block_ids: None, + } + .apply(&mut document, &mut session) + .is_none()); + assert_eq!( + document.blocks.len(), + 3, + "out-of-bounds attempt must not mutate" + ); + assert!(Command::ReplaceBlockRange { + index: 0, + remove_count: 1, + blocks: vec![para_block("a"), para_block("b")], + block_ids: Some(vec![original_ids[0].clone()]), // one id for two blocks + } + .apply(&mut document, &mut session) + .is_none()); + assert_eq!(document.blocks.len(), 3, "rejected replace must not mutate"); + assert_eq!(document.legacy_block_ids, original_ids); +} + +#[test] +fn command_table_cell_row_and_column_edits_round_trip() { + let mut document = table_doc(); + let mut session = DocumentSession::default(); + let inverse = Command::ReplaceTableCell { + block_idx: 0, + row: 1, + col: 1, + text: "D".into(), + } + .apply(&mut document, &mut session) + .unwrap(); + match &document.blocks[0] { + DocBlock::Table { cells, .. } => assert_eq!(cells[1][1].text, "D"), + other => panic!("expected table, got {other:?}"), + } + inverse.apply(&mut document, &mut session).unwrap(); + match &document.blocks[0] { + DocBlock::Table { cells, .. } => assert_eq!(cells[1][1].text, "d"), + other => panic!("expected table, got {other:?}"), + } + assert!(Command::ReplaceTableCell { + block_idx: 0, + row: 9, + col: 0, + text: "x".into(), + } + .apply(&mut document, &mut session) + .is_none()); + + let inverse = Command::InsertTableRow { + block_idx: 0, + row: 1, + cells: vec![ + CellContent { text: "x".into() }, + CellContent { text: "y".into() }, + ], + } + .apply(&mut document, &mut session) + .unwrap(); + match &document.blocks[0] { + DocBlock::Table { rows, cells, .. } => { + assert_eq!(*rows, 3); + assert_eq!(cells[1][0].text, "x"); + } + other => panic!("expected table, got {other:?}"), + } + inverse.apply(&mut document, &mut session).unwrap(); // RemoveTableRow + match &document.blocks[0] { + DocBlock::Table { rows, cells, .. } => { + assert_eq!(*rows, 2); + assert_eq!(cells[1][0].text, "c"); + } + other => panic!("expected table, got {other:?}"), + } + // Wrong-width rows are rejected; the sole remaining row cannot leave. + assert!(Command::InsertTableRow { + block_idx: 0, + row: 0, + cells: vec![CellContent { + text: "short".into() + }], + } + .apply(&mut document, &mut session) + .is_none()); + assert!(Command::RemoveTableRow { + block_idx: 0, + row: 0 + } + .apply(&mut document, &mut session) + .is_some()); + assert!(Command::RemoveTableRow { + block_idx: 0, + row: 0 + } + .apply(&mut document, &mut session) + .is_none()); + // Put the second row back for the column tests. + Command::InsertTableRow { + block_idx: 0, + row: 1, + cells: vec![ + CellContent { text: "c".into() }, + CellContent { text: "d".into() }, + ], + } + .apply(&mut document, &mut session) + .unwrap(); + + let inverse = Command::InsertTableColumn { + block_idx: 0, + col: 1, + cells: vec![ + CellContent { text: "m".into() }, + CellContent { text: "n".into() }, + ], + width: 77.0, + } + .apply(&mut document, &mut session) + .unwrap(); + match &document.blocks[0] { + DocBlock::Table { + cols, + col_widths, + cells, + .. + } => { + assert_eq!(*cols, 3); + assert_eq!(col_widths[1], 77.0); + assert_eq!(cells[0][1].text, "m"); + } + other => panic!("expected table, got {other:?}"), + } + inverse.apply(&mut document, &mut session).unwrap(); // RemoveTableColumn + match &document.blocks[0] { + DocBlock::Table { + cols, + col_widths, + cells, + .. + } => { + assert_eq!(*cols, 2); + assert_eq!(col_widths[1], 100.0); + assert_eq!(cells[0][1].text, "d", "row 0 is the re-inserted [c, d] row"); + } + other => panic!("expected table, got {other:?}"), + } + assert!(Command::InsertTableColumn { + block_idx: 0, + col: 0, + cells: vec![CellContent { + text: "short".into() + }], + width: 1.0, + } + .apply(&mut document, &mut session) + .is_none()); + Command::RemoveTableColumn { + block_idx: 0, + col: 0, + } + .apply(&mut document, &mut session) + .unwrap(); + assert!( + Command::RemoveTableColumn { + block_idx: 0, + col: 0 + } + .apply(&mut document, &mut session) + .is_none(), + "the last column cannot leave" + ); +} + +#[test] +fn command_merge_split_and_restore_table_cells_keep_cell_text() { + let mut document = table_doc(); + let mut session = DocumentSession::default(); + // Degenerate (single-cell) and out-of-bounds merges refuse. + assert!(Command::MergeTableCells { + selection: TableSelection { + block_idx: 0, + start_row: 0, + start_col: 0, + end_row: 0, + end_col: 0, + }, + } + .apply(&mut document, &mut session) + .is_none()); + assert!(Command::MergeTableCells { + selection: TableSelection { + block_idx: 0, + start_row: 0, + start_col: 0, + end_row: 9, + end_col: 9, + }, + } + .apply(&mut document, &mut session) + .is_none()); + + let inverse = Command::MergeTableCells { + selection: TableSelection { + block_idx: 0, + start_row: 0, + start_col: 0, + end_row: 1, + end_col: 1, + }, + } + .apply(&mut document, &mut session) + .expect("merge applies"); + match &document.blocks[0] { + DocBlock::Table { cells, .. } => { + assert_eq!( + cells[0][0].text, "a\nb\nc\nd", + "text joins in reading order" + ); + assert_eq!(cells[0][1].text, ""); + assert_eq!(cells[1][0].text, ""); + } + other => panic!("expected table, got {other:?}"), + } + assert_eq!(document.table_merges.len(), 1); + assert!(matches!(inverse, Command::RestoreTableMerge { .. })); + // Undo restores both the table block and the merge list. + inverse.apply(&mut document, &mut session).unwrap(); + assert!(document.table_merges.is_empty()); + + // Split removes merges and yields its own restoring inverse. + Command::MergeTableCells { + selection: TableSelection { + block_idx: 0, + start_col: 0, + start_row: 0, + end_row: 0, + end_col: 1, + }, + } + .apply(&mut document, &mut session) + .unwrap(); + assert_eq!(document.table_merges.len(), 1); + let inverse = Command::SplitTableCells { block_idx: 0 } + .apply(&mut document, &mut session) + .expect("split applies"); + assert!(document.table_merges.is_empty()); + inverse.apply(&mut document, &mut session).unwrap(); + assert_eq!( + document.table_merges.len(), + 1, + "split's inverse restores the merge" + ); + // Splitting a merge-free table is a no-op. + Command::SplitTableCells { block_idx: 0 } + .apply(&mut document, &mut session) + .unwrap(); + assert!(Command::SplitTableCells { block_idx: 0 } + .apply(&mut document, &mut session) + .is_none()); +} + +#[test] +fn command_node_insert_delete_replace_track_positions() { + let mut document = Document::new(vec![para_block("text")]); + let mut session = DocumentSession::default(); + document.nodes.push(aj_node(1, BlockKind::Divider)); + let inverse = Command::InsertNode { + parent: None, + index: 1, + node: aj_node(42, BlockKind::PageBreak), + } + .apply(&mut document, &mut session) + .expect("insert applies"); + assert_eq!(document.nodes.len(), 2); + assert_eq!(document.nodes[1].id, 42); + assert!(matches!(inverse, Command::DeleteNode { node: 42 })); + let inverse = inverse.apply(&mut document, &mut session).unwrap(); + assert_eq!(document.nodes.len(), 1); + assert!(matches!(inverse, Command::InsertNode { index: 1, .. })); + assert!(Command::InsertNode { + parent: None, + index: 9, + node: aj_node(99, BlockKind::Divider), + } + .apply(&mut document, &mut session) + .is_none()); + + let inverse = Command::ReplaceNode { + node: 1, + replacement: aj_node( + 7, + BlockKind::Audio { + resource: "a.mp3".into(), + }, + ), + } + .apply(&mut document, &mut session) + .expect("replace applies"); + assert!(matches!(document.nodes[0].kind, BlockKind::Audio { .. })); + inverse.apply(&mut document, &mut session).unwrap(); + assert_eq!(document.nodes[0].id, 1); + assert!(matches!(document.nodes[0].kind, BlockKind::Divider)); + assert!(Command::DeleteNode { node: 1234 } + .apply(&mut document, &mut session) + .is_none()); + assert!(Command::ReplaceNode { + node: 1234, + replacement: aj_node(1, BlockKind::Divider), + } + .apply(&mut document, &mut session) + .is_none()); +} + +#[test] +fn command_cursor_only_variants_expand_in_the_widget_not_the_model() { + let mut document = para_doc("abc"); + let mut session = DocumentSession::default(); + for command in [ + Command::DeleteBackward { at: cursor(0, 1) }, + Command::SplitBlock { at: cursor(0, 1) }, + Command::MergeBlock { at: cursor(0, 1) }, + Command::ToggleBold { at: cursor(0, 1) }, + Command::ToggleItalic { at: cursor(0, 1) }, + Command::ToggleUnderline { at: cursor(0, 1) }, + ] { + assert!( + command.apply(&mut document, &mut session).is_none(), + "the widget expands cursor edits into span/atom commands before apply" + ); + } + match &document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "abc"), + other => panic!("expected paragraph, got {other:?}"), + } +} + +// == controller + history: typing coalescing, CRDT lifecycle, remote ops ==== + +fn text_controller(text: &str) -> LegacyController { + LegacyController::with_document(para_doc(text)) +} + +#[test] +fn history_coalesces_contiguous_typing_and_limits_its_stack() { + let mut history = History::new(2); + history.push_undo(Transaction::single(Command::InsertBlock { + index: 0, + block: para_block("x"), + })); + history.push_undo(Transaction::single(Command::InsertBlock { + index: 1, + block: para_block("y"), + })); + history.push_undo(Transaction::single(Command::InsertBlock { + index: 2, + block: para_block("z"), + })); + assert_eq!(history.undo.len(), 2, "the limit evicts the oldest entry"); + // Non-text transactions are never coalesced, even through the typing lane. + history.push_typing_undo(Transaction::single(Command::InsertBlock { + index: 3, + block: para_block("w"), + })); + assert_eq!(history.undo.len(), 2); + + let mut controller = text_controller(""); + controller.execute(Command::InsertText { + at: cursor(0, 0), + text: "a".into(), + }); + controller.execute(Command::InsertText { + at: cursor(0, 1), + text: "b".into(), + }); + assert_eq!( + controller.history.undo.len(), + 1, + "adjacent typing coalesces" + ); + assert_eq!(controller.history.undo[0].commands.len(), 2); + controller.execute(Command::InsertText { + at: cursor(0, 0), + text: "c".into(), + }); + assert_eq!( + controller.history.undo.len(), + 2, + "a jump breaks the typing run" + ); + let revision_before = controller.document.metadata.revision; + controller.execute(Command::InsertText { + at: cursor(0, 0), + text: "!".into(), + }); + assert!(controller.document.metadata.revision > revision_before); + assert!(controller.history.redo.is_empty(), "new edits clear redo"); + assert!(controller.undo() && controller.undo() && controller.undo()); + match &controller.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, ""), + other => panic!("expected paragraph, got {other:?}"), + } + assert!(!controller.undo(), "an empty undo stack reports false"); + assert!(controller.redo() && controller.redo()); + match &controller.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "cab"), + other => panic!("expected paragraph, got {other:?}"), + } +} + +#[test] +fn controller_failed_commands_leave_no_history_or_revision_trace() { + let mut controller = LegacyController::with_document(Document::new(vec![DocBlock::Divider])); + let revision = controller.document.metadata.revision; + assert!(!controller.execute(Command::ReplaceSpans { + block_idx: 0, + spans: vec![], + })); + assert_eq!(controller.document.metadata.revision, revision); + assert!(controller.history.undo.is_empty()); + assert!( + !controller.execute_transaction(vec![]), + "empty transactions fail" + ); + assert!(!controller.execute_transaction(vec![Command::ReplaceSpans { + block_idx: 99, + spans: vec![], + },])); + assert!(controller.history.undo.is_empty()); + assert!(!controller.redo(), "an empty redo stack reports false"); + // Recording a transaction directly still feeds undo + collaboration. + controller.record_transaction(Transaction::single(Command::RemoveBlock { index: 0 })); + assert_eq!(controller.history.undo.len(), 1); + assert_eq!(controller.collaboration.outbound.len(), 1); + // A legacy transaction snapshot undoes as one step. + controller.begin_legacy_transaction(); + assert_eq!(controller.history.undo.len(), 2); +} + +#[test] +fn controller_crdt_typing_lifecycle_seeds_edits_and_tracks_positions() { + let mut controller = text_controller(""); + assert!(controller.insert_text_crdt(cursor(0, 0), "hi".into())); + let (text, atom_count) = match &controller.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => { + let span = &spans[0]; + ( + span.text.clone(), + span.crdt_text + .as_ref() + .expect("seeded") + .visible_atoms() + .len(), + ) + } + other => panic!("expected paragraph, got {other:?}"), + }; + assert_eq!(text, "hi"); + assert_eq!(atom_count, 2); + assert_eq!(controller.session.cursor.unwrap().char_offset, 2); + let crdt_cursor = controller + .session + .crdt_cursor + .as_ref() + .expect("crdt cursor synced"); + assert!( + crdt_cursor.after.is_some(), + "the caret anchors after the last typed atom" + ); + assert!(controller.session.selection_anchor.is_none()); + assert!(controller.session.crdt_selection.focus.is_some()); + + // Backspace one atom. + assert!(controller.delete_backward_crdt(cursor(0, 2))); + match &controller.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "h"), + other => panic!("expected paragraph, got {other:?}"), + } + assert_eq!(controller.session.cursor.unwrap().char_offset, 1); + assert!( + !controller.delete_backward_crdt(cursor(0, 0)), + "nothing before offset 0" + ); + // Forward delete removes the remaining atom; past the end fails. + assert!(controller.delete_forward_crdt(cursor(0, 0))); + assert!(!controller.delete_forward_crdt(cursor(0, 0))); + match &controller.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, ""), + other => panic!("expected paragraph, got {other:?}"), + } + // Table-cell cursors take the legacy lane, which cells are not spans on. + assert!(!controller.insert_text_crdt( + DocCursor { + cell_pos: Some((0, 0)), + ..cursor(0, 0) + }, + "x".into(), + )); + // Unknown blocks fail cleanly. + assert!(!controller.insert_text_crdt(cursor(9, 0), "x".into())); + assert!(!controller.delete_backward_crdt(cursor(9, 1))); + assert!(!controller.delete_forward_crdt(cursor(9, 0))); +} + +#[test] +fn controller_crud_range_operations_require_seeded_atoms() { + let mut controller = text_controller(""); + // Range edits need seeded atom ids; a plain legacy span has none. + assert!(!controller.replace_range_crdt(cursor(0, 0), cursor(0, 1), "Y".into())); + assert!(!controller.delete_range_crdt(cursor(0, 0), cursor(0, 1))); + assert!(controller.insert_text_crdt(cursor(0, 0), "hello".into())); + assert!(controller.replace_range_crdt(cursor(0, 1), cursor(0, 3), "Y".into())); + match &controller.document.blocks[0] { + // RGA sibling ordering: the new atom anchors on the predecessor of + // the deleted range, and the tombstoned subtree still walks ahead of + // it, so "Y" renders after the (hidden) "el" anchors -> "hloY". + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "hloY"), + other => panic!("expected paragraph, got {other:?}"), + } + assert_eq!(controller.session.cursor.unwrap().char_offset, 2); + // Empty ranges and cell/cross-block cursors fail without mutation. + assert!(!controller.replace_range_crdt(cursor(0, 1), cursor(0, 1), "q".into())); + assert!(!controller.delete_range_crdt(cursor(0, 1), cursor(0, 1))); + assert!(!controller.replace_range_crdt(cursor(0, 0), cursor(1, 1), "q".into())); + assert!(!controller.delete_range_crdt( + DocCursor { + cell_pos: Some((0, 0)), + ..cursor(0, 0) + }, + cursor(0, 1), + )); + assert!(controller.delete_range_crdt(cursor(0, 1), cursor(0, 3))); + match &controller.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "hY"), + other => panic!("expected paragraph, got {other:?}"), + } + assert_eq!(controller.session.cursor.unwrap().char_offset, 1); +} + +#[test] +fn controller_remote_operations_classify_apply_duplicate_defer_reject() { + use super::collaboration::{DocumentOperation, OperationId}; + use super::editing::RemoteApplyResult; + + fn op(sequence: u64, base_revision: u64, command: Command) -> DocumentOperation { + DocumentOperation { + id: OperationId { + actor: "peer".into(), + sequence, + }, + base_revision, + transaction: Transaction::single(command), + } + } + + let mut controller = LegacyController::default(); + // CRDT-safe commands apply even when their causal base is ahead. + let block_id = BlockId(AtomId { + actor: "peer".into(), + sequence: 1, + }); + let result = controller.receive_remote_operation(op( + 1, + 999, + Command::InsertBlockAfter { + after: None, + block_id: block_id.clone(), + block: para_block("remote"), + }, + )); + assert_eq!(result, RemoteApplyResult::Applied); + assert_eq!(controller.document.blocks.len(), 1); + // Same operation id again -> duplicate, no re-apply. + let result = controller.receive_remote_operation(op( + 1, + 999, + Command::InsertBlockAfter { + after: None, + block_id, + block: para_block("remote"), + }, + )); + assert_eq!(result, RemoteApplyResult::Duplicate); + assert_eq!(controller.document.blocks.len(), 1); + // Legacy index commands from the future wait in the causal buffer. + let result = controller.receive_remote_operation(op( + 2, + 999, + Command::ReplaceSpans { + block_idx: 0, + spans: vec![StyleSpan { + text: "future".into(), + ..Default::default() + }], + }, + )); + assert_eq!(result, RemoteApplyResult::Deferred); + assert_eq!(controller.collaboration.pending_remote.len(), 1); + // Once the local revision advances far enough, the buffered op drains. + controller.document.metadata.revision = 1000; + controller.drain_remote_buffer(); + assert!(controller.collaboration.pending_remote.is_empty()); + match &controller.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "future"), + other => panic!("expected paragraph, got {other:?}"), + } + // Malformed ops are rejected, not applied. + let result = controller.receive_remote_operation(op( + 3, + 0, + Command::ReplaceSpans { + block_idx: 99, + spans: vec![], + }, + )); + assert_eq!(result, RemoteApplyResult::Rejected); + // The bool convenience wrapper reports only Applied as success. + assert!(controller.apply_remote_operation(op( + 4, + 0, + Command::DeleteBlockById { + block_id: controller.document.legacy_block_ids[0].clone(), + }, + ))); + assert!(controller.document.blocks.is_empty()); +} + +#[test] +fn controller_compacts_tombstones_only_at_a_peer_acknowledged_frontier() { + let mut controller = text_controller(""); + assert!(controller.insert_text_crdt(cursor(0, 0), "hi".into())); + assert!(controller.delete_backward_crdt(cursor(0, 2))); + let atoms_before = match &controller.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => spans[0].crdt_text.as_ref().unwrap().atoms.len(), + other => panic!("expected paragraph, got {other:?}"), + }; + assert_eq!(atoms_before, 2, "the tombstone is still tracked"); + // No acknowledged frontier: nothing compacts. + controller.compact_acknowledged_tombstones(); + let atoms_after_noop = match &controller.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => spans[0].crdt_text.as_ref().unwrap().atoms.len(), + other => panic!("expected paragraph, got {other:?}"), + }; + assert_eq!(atoms_after_noop, 2); + // Every peer must acknowledge; the slowest peer gates compaction. + controller + .collaboration + .acknowledge_frontier("alice".into(), 999); + controller + .collaboration + .acknowledge_frontier("bob".into(), 100); + assert_eq!(controller.collaboration.safe_frontier(), Some(100)); + controller.compact_acknowledged_tombstones(); + match &controller.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => { + let rga = spans[0].crdt_text.as_ref().unwrap(); + assert_eq!(rga.atoms.len(), 1, "acknowledged tombstones compact away"); + assert_eq!(rga.visible_string(), "h"); + } + other => panic!("expected paragraph, got {other:?}"), + } +} + +#[test] +fn controller_resolves_remote_positions_and_presence_into_legacy_cursors() { + use super::collaboration::Presence; + + let mut controller = text_controller(""); + assert!(controller.insert_text_crdt(cursor(0, 0), "abc".into())); + let atom_ids: Vec = match &controller.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => spans[0] + .crdt_text + .as_ref() + .unwrap() + .visible_atoms() + .iter() + .map(|atom| atom.id.clone()) + .collect(), + other => panic!("expected paragraph, got {other:?}"), + }; + let position = CrdtTextPosition { + block_idx: 0, + span_idx: 0, + after: Some(atom_ids[1].clone()), + }; + assert_eq!( + controller + .resolve_crdt_position(&position) + .unwrap() + .char_offset, + 2 + ); + // Missing anchors resolve to the start of the span instead of failing. + assert_eq!( + controller + .resolve_crdt_position(&CrdtTextPosition { + block_idx: 0, + span_idx: 0, + after: Some(AtomId { + actor: "ghost".into(), + sequence: 1, + }), + }) + .unwrap() + .char_offset, + 0 + ); + assert_eq!( + controller + .resolve_crdt_position(&CrdtTextPosition { + block_idx: 0, + span_idx: 0, + after: None, + }) + .unwrap() + .char_offset, + 0 + ); + assert!(controller + .resolve_crdt_position(&CrdtTextPosition { + block_idx: 42, + span_idx: 0, + after: None, + }) + .is_none()); + + // Presence rides through resolution and lands in the peer table. + let presence = Presence { + actor: "peer".into(), + display_name: "Peer".into(), + crdt_cursor: Some(CrdtTextPosition { + block_idx: 0, + span_idx: 0, + after: Some(atom_ids[2].clone()), + }), + ..Default::default() + }; + controller.apply_remote_presence(presence); + let stored = controller + .collaboration + .presences + .get("peer") + .expect("stored"); + assert_eq!(stored.cursor.unwrap().char_offset, 3); + let mut plain = Presence { + actor: "plain".into(), + ..Default::default() + }; + plain.cursor = Some(cursor(0, 1)); + controller.apply_remote_presence(plain); + assert_eq!( + controller.collaboration.presences["plain"] + .cursor + .unwrap() + .char_offset, + 1, + "presence without CRDT anchors keeps its legacy cursor" + ); + + // Legacy cursors sync back into CRDT anchors for sharing. + controller.session.set_cursor(Some(cursor(0, 2))); + controller.sync_crdt_cursor_from_legacy(cursor(0, 2)); + assert_eq!( + controller.session.crdt_cursor.as_ref().unwrap().after, + Some(atom_ids[1].clone()) + ); + controller.session.cursor = Some(cursor(0, 0)); + controller.sync_crdt_cursor_from_legacy(cursor(0, 0)); + assert_eq!(controller.session.crdt_cursor.as_ref().unwrap().after, None); +} + +#[test] +fn controller_synchronize_exchanges_operations_acks_and_presence() { + use super::collaboration::{MemoryTransport, Presence}; + + let mut alice = text_controller(""); + alice.collaboration.local_actor = "alice".into(); + alice.document.crdt.local_actor = "alice".into(); + let mut bob = LegacyController::with_document(para_doc("")); + bob.collaboration.local_actor = "bob".into(); + bob.document.crdt.local_actor = "bob".into(); + let mut wire = MemoryTransport::default(); + + assert!(alice.insert_text_crdt(cursor(0, 0), "hi".into())); + alice.synchronize(&mut wire); + // Everything alice produced moved onto the wire. + assert!(!wire.outgoing_operations.is_empty()); + assert!(!wire.outgoing_acks.is_empty()); + // The loopback host ferries alice's outgoing side into bob's incoming side. + wire.incoming_operations + .append(&mut wire.outgoing_operations); + wire.incoming_acks.append(&mut wire.outgoing_acks); + wire.incoming_presences.push_back(Presence { + actor: "observer".into(), + ..Default::default() + }); + bob.synchronize(&mut wire); + match &bob.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "hi"), + other => panic!("expected paragraph, got {other:?}"), + } + assert!(bob.collaboration.presences.contains_key("observer")); + // bob replies; his edit + ack flow back over the same loop. + assert!(bob.insert_text_crdt(cursor(0, 2), "!".into())); + bob.synchronize(&mut wire); + wire.incoming_operations + .append(&mut wire.outgoing_operations); + wire.incoming_acks.append(&mut wire.outgoing_acks); + alice.synchronize(&mut wire); + match &alice.document.blocks[0] { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "hi!"), + other => panic!("expected paragraph, got {other:?}"), + } + assert!( + alice + .collaboration + .acknowledged_frontiers + .contains_key("bob"), + "acks record per-actor clock frontiers" + ); +} + +// == model: small helpers on selection, session, document, and CRDT ======== + +#[test] +fn model_selection_and_session_helpers_track_collapsed_state() { + assert!(Selection::collapsed(Some(cursor(0, 3))).is_collapsed()); + assert!(Selection::collapsed(None).is_collapsed()); + let mut session = DocumentSession::default(); + session.set_cursor(Some(cursor(0, 2))); + assert_eq!(session.cursor.unwrap().char_offset, 2); + assert_eq!(session.selection.anchor.unwrap().char_offset, 2); + // An anchor survives a later cursor move while the user drags. + session.selection_anchor = Some(cursor(0, 0)); + session.set_cursor(Some(cursor(0, 5))); + assert_eq!(session.selection_anchor.unwrap().char_offset, 0); + session.clear_selection(); + assert!(session.selection_anchor.is_none()); + assert_eq!(session.selection.focus.unwrap().char_offset, 5); + // Table cell selections normalize in either drag direction. + let normalized = TableSelection { + block_idx: 0, + start_row: 3, + start_col: 2, + end_row: 1, + end_col: 0, + } + .normalized(); + assert_eq!( + ( + normalized.start_row, + normalized.start_col, + normalized.end_row, + normalized.end_col + ), + (1, 0, 3, 2) + ); +} + +#[test] +fn document_stable_id_paths_reuse_and_resurrect_atoms() { + let mut document = Document::new(vec![para_block("a"), para_block("b")]); + document.ensure_legacy_block_ids(); + assert_eq!(document.legacy_block_ids.len(), 2); + assert_eq!( + document.block_revision(99), + 0, + "unknown indices report revision 0" + ); + let reused = document.legacy_block_ids[0].clone(); + assert!(document.insert_legacy_block_with_id(1, reused.clone(), para_block("mid"))); + assert_eq!(document.legacy_block_ids[1], reused); + assert_eq!(document.legacy_block_ids.len(), 3); + // Removal hands the block back and drops the id from the visible order. + let removed = document.remove_legacy_block(1).expect("present"); + match removed { + DocBlock::Paragraph { spans, .. } => assert_eq!(spans[0].text, "mid"), + other => panic!("expected paragraph, got {other:?}"), + } + assert_eq!(document.legacy_block_ids.len(), 2); + assert!(document.remove_legacy_block(99).is_none()); + assert!(!document.insert_legacy_block(99, para_block("nope"))); + assert_eq!(document.blocks.len(), 2); +} + +#[test] +fn block_order_crdt_hides_tombstones_and_rejects_duplicate_ids() { + let mut order = BlockOrderCrdt::default(); + let first = BlockId(AtomId { + actor: "a".into(), + sequence: 1, + }); + let second = BlockId(AtomId { + actor: "a".into(), + sequence: 2, + }); + let atom = |id: BlockId, after: Option| BlockAtom { + id, + after, + deleted: false, + deleted_at: None, + timestamp: LamportTimestamp { counter: 1 }, + }; + assert!(order.insert(atom(first.clone(), None))); + assert!( + !order.insert(atom(first.clone(), None)), + "duplicate ids are refused" + ); + assert!(order.insert(atom(second.clone(), Some(first.clone())))); + assert_eq!(order.visible_ids(), vec![first.clone(), second.clone()]); + order.tombstone(&first); + // A tombstoned parent still anchors its children; it just stops listing. + assert_eq!(order.visible_ids(), vec![second]); + order.tombstone(&BlockId(AtomId { + actor: "ghost".into(), + sequence: 9, + })); +} + +#[test] +fn rga_compaction_relinks_children_to_the_removed_parent() { + let mut document = para_doc(""); + let mut session = DocumentSession::default(); + let first = TextAtom { + id: AtomId { + actor: "a".into(), + sequence: 1, + }, + after: None, + ch: 'x', + deleted: false, + deleted_at: None, + timestamp: LamportTimestamp { counter: 1 }, + }; + let second = TextAtom { + id: AtomId { + actor: "a".into(), + sequence: 2, + }, + after: Some(first.id.clone()), + ch: 'y', + deleted: false, + deleted_at: None, + timestamp: LamportTimestamp { counter: 2 }, + }; + Command::InsertAtoms { + block_idx: 0, + span_idx: 0, + atoms: vec![first.clone(), second.clone()], + } + .apply(&mut document, &mut session) + .unwrap(); + let rga = match &mut document.blocks[0] { + DocBlock::Paragraph { spans, .. } => spans[0].crdt_text.as_mut().unwrap(), + other => panic!("expected paragraph, got {other:?}"), + }; + rga.tombstone_at(vec![first.id.clone()], LamportTimestamp { counter: 3 }); + assert_eq!(rga.visible_string(), "y"); + // Before the frontier nothing is collected. + rga.compact_tombstones_before(LamportTimestamp { counter: 1 }); + assert_eq!(rga.atoms.len(), 2); + rga.compact_tombstones_before(LamportTimestamp { counter: 3 }); + assert_eq!(rga.atoms.len(), 1); + assert_eq!( + rga.visible_string(), + "y", + "the survivor re-anchored to the root" + ); +} + +#[test] +fn block_kind_default_and_page_metrics_cover_base_geometry() { + assert!(matches!(BlockKind::default(), BlockKind::Paragraph { inlines } if inlines.is_empty())); + let metrics = PageMetrics { + width: 500.0, + height: 700.0, + margin_left: 40.0, + margin_right: 40.0, + margin_top: 40.0, + margin_bottom: 40.0, + line_spacing: 1.2, + }; + assert_eq!(metrics.content_width(), 420.0); + let cramped = PageMetrics { + width: 10.0, + margin_left: 40.0, + margin_right: 40.0, + ..metrics + }; + assert_eq!( + cramped.content_width(), + 1.0, + "margins can never erase the content lane" + ); +} + +// == projection layout: cell text editing, geometry, and table navigation == + +use super::projection_layout::{ + cell_range_mergeable, cell_selection_rects, cell_text_backspace, cell_text_delete, + cell_text_insert, merge_at_cell, neighbor_cell, neighbor_text_block, rect_union, + table_cell_caret, table_cell_cursor_at, table_cell_position, table_cell_range, table_cell_text, +}; +use super::projection_session::{TableCellCursor, TableCellSelection}; + +#[test] +fn projected_node_metrics_labels_every_kind() { + for (kind, height, label, interactive) in [ + ("paragraph", 28.0, "Paragraph", false), + ("heading", 36.0, "Heading", false), + ("list", 28.0, "List", false), + ("quote", 56.0, "Quote", false), + ("code", 40.0, "Code", false), + ("table", 36.0, "Table", true), + ("image", 220.0, "Image", true), + ("divider", 18.0, "Divider", false), + ("page_break", 36.0, "Page Break", false), + ("columns", 72.0, "Columns", true), + ("container", 72.0, "Container", true), + ("canvas", 240.0, "Canvas", true), + ("audio", 52.0, "Audio", true), + ("video", 180.0, "Video", true), + ("diagram", 160.0, "Diagram", true), + ("sparkline", 64.0, "Widget: sparkline", true), + ] { + assert_eq!( + projected_node_metrics(kind), + (height, label.to_string(), interactive), + "kind {kind}" + ); + } +} + +#[test] +fn cell_text_editing_is_char_safe_and_clamped() { + assert_eq!(cell_text_insert("ab", 1, "X"), ("aXb".to_string(), 2)); + assert_eq!(cell_text_insert("ab", 99, "X"), ("abX".to_string(), 3)); + assert_eq!(cell_text_insert("hé", 2, "!"), ("hé!".to_string(), 3)); + + assert_eq!(cell_text_backspace("hello", 0), None); + assert_eq!( + cell_text_backspace("hello", 5), + Some(("hell".to_string(), 4)) + ); + assert_eq!(cell_text_backspace("hi", 9), None); + + assert_eq!( + cell_text_replace_range("hello", 1, 3, "Y"), + ("hYlo".to_string(), 2) + ); + assert_eq!( + cell_text_replace_range("hello", 3, 1, "Y"), + ("hYlo".to_string(), 2), + "reversed ranges are normalized" + ); + assert_eq!( + cell_text_replace_range("hi", 0, 99, ""), + ("".to_string(), 0) + ); + + assert_eq!(cell_text_delete("hello", 0), Some(("ello".to_string(), 0))); + assert_eq!(cell_text_delete("hi", 2), None); + assert_eq!(cell_text_delete("hé", 1), Some(("h".to_string(), 1))); +} + +#[test] +fn cell_text_multiline_geometry_counts_and_maps_display_lines() { + let text = "ab\ncd\n"; + assert_eq!( + cell_text_line_count(text), + 3, + "the trailing newline keeps its line" + ); + assert_eq!(cell_text_line_spans(text), vec![(0, 2), (3, 2), (6, 0)]); + assert_eq!(cell_text_line_col(text, 0), (0, 0)); + assert_eq!(cell_text_line_col(text, 3), (1, 0)); + assert_eq!(cell_text_line_col(text, 4), (1, 1)); + assert_eq!(cell_text_line_col(text, 99), (2, 0)); + assert_eq!(cell_text_offset_at(text, 1, 1), 4); + assert_eq!( + cell_text_offset_at(text, 0, 99), + 2, + "columns clamp to the line" + ); + assert_eq!( + cell_text_offset_at(text, 9, 0), + 6, + "lines clamp to the last line" + ); + assert_eq!(cell_text_line_count(""), 1); + assert_eq!(cell_text_line_spans(""), vec![(0, 0)]); + + let cell = makepad_widgets::Rect { + pos: dvec2(0.0, 20.0), + size: dvec2(100.0, 60.0), + }; + assert!( + (cell_text_origin_y(cell, 1) - 41.0).abs() < 1e-9, + "single line centers" + ); + assert!((cell_text_origin_y(cell, 2) - 32.0).abs() < 1e-9); +} + +#[test] +fn cell_text_span_rects_and_unions_follow_the_shared_grid() { + let cell = makepad_widgets::Rect { + pos: dvec2(10.0, 20.0), + size: dvec2(100.0, 60.0), + }; + let rects = cell_text_span_rects(cell, "ab\ncd", 1, 4); + assert_eq!(rects.len(), 2, "the selection covers one glyph per line"); + let y0 = cell_text_origin_y(cell, 2); + assert!((rects[0].pos.x - (10.0 + TABLE_CELL_TEXT_INSET + TEXT_CHAR_ADVANCE)).abs() < 1e-9); + assert!((rects[0].pos.y - y0).abs() < 1e-9); + assert!((rects[1].pos.y - (y0 + TABLE_CELL_TEXT_LINE_HEIGHT)).abs() < 1e-9); + assert!((rects[0].size.x - TEXT_CHAR_ADVANCE).abs() < 1e-9); + // Reversed selections and collapsed selections. + assert_eq!(cell_text_span_rects(cell, "ab\ncd", 4, 1).len(), 2); + assert!(cell_text_span_rects(cell, "ab\ncd", 2, 2).is_empty()); + let single = cell_text_span_rects(cell, "hello", 0, 5); + assert_eq!(single.len(), 1); + assert!((single[0].size.x - 5.0 * TEXT_CHAR_ADVANCE).abs() < 1e-9); + + let a = makepad_widgets::Rect { + pos: dvec2(0.0, 0.0), + size: dvec2(10.0, 10.0), + }; + let b = makepad_widgets::Rect { + pos: dvec2(5.0, 20.0), + size: dvec2(10.0, 30.0), + }; + let union = rect_union(a, b); + assert_eq!(union.pos, dvec2(0.0, 0.0)); + assert_eq!(union.size, dvec2(15.0, 50.0)); +} + +#[test] +fn cell_char_offset_splits_chars_at_midpoints_and_clamps_lines() { + let (engine, ..) = projection_table_engine(); + let tree = layout_projection(&engine.projection); + let table = only_table(&engine); + let _ = tree; + let cell = table.cell(0, 0).expect("cell"); + assert_eq!(cell.text, "qty"); + let y0 = cell_text_origin_y(cell.rect, 1); + let at = |x: f64, y: f64| cell_char_offset_at(cell, dvec2(x, y)); + let base_x = cell.rect.pos.x + TABLE_CELL_TEXT_INSET; + assert_eq!(at(base_x + 0.1, y0 + 1.0), 0); + assert_eq!( + at( + base_x + TEXT_CHAR_ADVANCE + TEXT_CHAR_ADVANCE * 0.5, + y0 + 1.0 + ), + 2, + "past the midpoint the tap belongs to the next char" + ); + assert_eq!( + at(base_x + 999.0, y0), + 3, + "right of the line clamps to its end" + ); + assert_eq!( + at(cell.rect.pos.x - 50.0, y0), + 0, + "left of the inset clamps to zero" + ); + assert_eq!( + at(base_x + 1.0, y0 - 100.0), + 0, + "above the text clamps to line 0" + ); +} + +#[test] +fn table_cell_lookup_cursors_and_carets_round_trip_through_layout() { + let (engine, ..) = projection_table_engine(); + let tree = layout_projection(&engine.projection); + let cursor = + table_cell_cursor_at(&tree, &engine.projection, 0, 1, 1, 99).expect("cursor resolves"); + assert_eq!(cursor.offset, 4, "offsets clamp to the cell text length"); + assert_eq!( + table_cell_position(&tree, &engine.projection, &cursor), + Some((0, 1, 1)) + ); + assert_eq!(table_cell_text(&engine.projection, &cursor), "bags"); + let caret = table_cell_caret(&tree, &engine.projection, &cursor).expect("caret"); + let cell = only_table(&engine).cell(1, 1).unwrap().clone(); + assert!( + (caret.pos.x - (cell.rect.pos.x + TABLE_CELL_TEXT_INSET + 4.0 * TEXT_CHAR_ADVANCE)).abs() + < 1e-9 + ); + assert!((caret.size.x - 2.0).abs() < 1e-9); + // Stale ids resolve to nothing instead of pointing at the wrong cell. + let stale = TableCellCursor { + table: OpId { + actor: "ghost".into(), + counter: 1, + }, + ..cursor.clone() + }; + assert!(table_cell_position(&tree, &engine.projection, &stale).is_none()); + assert_eq!(table_cell_text(&engine.projection, &stale), ""); + assert!(table_cell_cursor_at(&tree, &engine.projection, 0, 9, 0, 0).is_none()); +} + +#[test] +fn table_cell_ranges_merge_rules_and_selection_rects_track_merges() { + let (mut engine, table, row0, row1, col0, col1) = projection_table_engine(); + let tree = layout_projection(&engine.projection); + // The rectangular selection normalizes in either drag direction. + let selection = TableCellSelection { + table: table.clone(), + anchor_row: row1.clone(), + anchor_column: col1.clone(), + focus_row: row0.clone(), + focus_column: col0.clone(), + }; + let projected = &engine.projection.tables[&format!("{}:{}", table.actor, table.counter)]; + assert_eq!(table_cell_range(projected, &selection), Some((0, 0, 1, 1))); + let stale = TableCellSelection { + focus_row: OpId { + actor: "ghost".into(), + counter: 1, + }, + ..selection.clone() + }; + assert_eq!(table_cell_range(projected, &stale).is_none(), true); + + // A clean 2x2 table merges any multi-cell rectangle. + let layout = only_table(&engine); + assert!(cell_range_mergeable(&layout, (0, 0, 1, 1))); + assert!(!cell_range_mergeable(&layout, (0, 0, 0, 0))); + assert_eq!(cell_selection_rects(&layout, (0, 0, 1, 1)).len(), 4); + + // After merging the top row, its cells are no longer merge candidates, + // covered cells drop out of selection rects, and merge ids resolve. + engine.merge_table_cells("t", table.clone(), row0.clone(), col0.clone(), row0, col1); + let tree = layout_projection(&engine.projection); + let _ = tree; + let layout = only_table(&engine); + assert!( + !cell_range_mergeable(&layout, (0, 0, 1, 1)), + "merged cells block re-merge" + ); + assert_eq!( + cell_selection_rects(&layout, (0, 0, 1, 1)).len(), + 3, + "covered cells skip" + ); + let projected = &engine.projection.tables[&format!("{}:{}", table.actor, table.counter)]; + assert!( + merge_at_cell(projected, 0, 1).is_some(), + "covered cells find their merge" + ); + assert!(merge_at_cell(projected, 1, 1).is_none()); +} + +#[test] +fn neighbor_cells_and_text_blocks_follow_reading_order() { + assert_eq!(neighbor_cell(2, 2, 0, 0, true), Some((0, 1))); + assert_eq!(neighbor_cell(2, 2, 0, 1, true), Some((1, 0)), "rows wrap"); + assert_eq!(neighbor_cell(2, 2, 1, 0, false), Some((0, 1))); + assert_eq!( + neighbor_cell(2, 2, 1, 1, true), + None, + "forward exits at the end" + ); + assert_eq!(neighbor_cell(2, 2, 0, 0, false), None); + assert_eq!(neighbor_cell(0, 2, 0, 0, true), None); + + // Text blocks neighbor tables in the unified order, skipping nodes. + let mut engine = CrdtController::default(); + let before = engine.insert_block("t", None, "paragraph").unwrap(); + engine.insert_text("t", before.clone(), None, "before"); + let table = engine + .insert_block("t", Some(before.clone()), "table") + .unwrap(); + engine.insert_node("t", None, Some(table.clone()), "canvas", "{}"); + let after = engine.insert_block("t", None, "paragraph").unwrap(); + engine.insert_text("t", after.clone(), None, "after"); + let table_id = format!("{}:{}", table.actor, table.counter); + let forward = neighbor_text_block(&engine.projection, &table_id, true).expect("after"); + assert_eq!(forward.counter, after.counter, "nodes are skipped forward"); + let backward = neighbor_text_block(&engine.projection, &table_id, false).expect("before"); + assert_eq!(backward.counter, before.counter); + // A lone table has no text neighbors at all. + let (tables_only, ..) = projection_table_engine(); + let table_id = layout_projection(&tables_only.projection).tables[0] + .block_id + .clone(); + assert!(neighbor_text_block(&tables_only.projection, &table_id, true).is_none()); + assert!(neighbor_text_block(&tables_only.projection, &table_id, false).is_none()); +} diff --git a/tools/test-doc-workspace-coverage.sh b/tools/test-doc-workspace-coverage.sh new file mode 100755 index 0000000..1906b3c --- /dev/null +++ b/tools/test-doc-workspace-coverage.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# Temporary LLVM source-coverage run for the doc workspace's pure logic. +# +# WHAT THIS COVERS +# ---------------- +# The doc module (construction_frame/pages/workspace/doc) is ~20k lines, +# most of it widget code that needs live_design!, Cx and an event loop. +# But the module's CORE is dependency-free: the document model types, +# legacy layout engine pieces, collaboration glue, the projection +# layout tree (glyph/rect geometry, hit tests, selection handles), the +# projection session (save/load wire), advanced-node JSON, clipboard / +# style editing logic, persistence and the mobile gesture state machine. +# Those files import only the makepad-math types (DVec2/Rect/Vec4f, +# dvec2/vec4), doc-engine (pure Rust), serde and std. This harness +# copies them into a host-only crate that carries the SAME module path +# (`nigig_build::construction_frame::pages::workspace::doc::*`), so the +# sources compile byte-for-byte with no edits, no GUI, no windowing +# system and no platform startup — the CAD harness pattern, applied to +# the doc surface. +# +# The test driver is the crate's own tests_pure.rs: the doc test suite +# was split so that every dependency-free test lives there (widget +# runtime tests stay in tests.rs). The harness copies it byte-for-byte +# as `#[cfg(test)] mod tests_pure`, so the measurement is exactly the +# pure suite the lib run executes — single-sourced, no drift. +# +# WHAT IT EXCLUDES FROM THE REPORT (step 4 of the coverage plan) +# - the Makepad checkout -- generated/vendored upstream code +# - the cargo registry and git dirs -- third-party code +# - the rustc sysroot -- std +# - harness/src/lib.rs, shim/ -- the platform-startup stand-ins +# this script writes itself; scaffolding, not doc code, and counting +# it would flatter the number for no reason. +# Widget files (crdt_widget.rs, widgets/, render/, +# projection_renderer.rs, mod.rs) stay unmeasured here on purpose: they +# are gated by the crate's own lib suite in CI, and this script does not +# pretend to measure them. +# +# USAGE +# ./tools/test-doc-workspace-coverage.sh # run, enforce floors, clean up +# KEEP_COVERAGE=1 ./tools/test-doc-workspace-coverage.sh # keep env + uncovered lines +# DOC_WS_COVERAGE_REPORT_ONLY=1 ./tools/test-doc-workspace-coverage.sh # measure, don't gate +# +# Everything -- toolchain, cargo home, target dir, profraw data, the +# fetched Makepad tree and the report -- lives under a single mktemp +# directory a shell trap removes on success, failure, interrupt or +# termination. Nothing is written into the repository or $HOME. +set -Eeuo pipefail +IFS=$'\n\t' + +ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" + +# Floors, set a couple of points under today's measurement so ordinary +# refactoring does not trip them while a real loss of coverage does. +# A single total hides the failure this is meant to catch: losing every +# test in one file moves the total by a point or two and a lone number +# would wave that through. Lowering a floor is a reviewable edit here, +# not something to do quietly. +TOTAL_FLOOR="${DOC_WS_COVERAGE_TOTAL_FLOOR:-92}" +PER_FILE_FLOORS="${DOC_WS_COVERAGE_PER_FILE_FLOORS:-\ +projection_layout.rs:95 +projection_session.rs:95 +mobile_gesture.rs:95 +persistence.rs:55 +advanced_json.rs:94 +crdt_bridge.rs:91 +collaboration/session.rs:95 +collaboration/transport.rs:92 +editing/commands.rs:89 +editing/controller.rs:89 +editing/history.rs:91 +layout/advanced_layout.rs:90 +layout/block_cache.rs:90 +layout/block_layout.rs:90 +layout/divider_layout.rs:90 +layout/hit_test.rs:90 +layout/image_layout.rs:90 +layout/layout_engine.rs:90 +layout/layout_tree.rs:90 +layout/mod.rs:90 +layout/page_cache.rs:90 +layout/page_layout.rs:90 +layout/table_layout.rs:90 +model/advanced.rs:92 +model/crdt.rs:93 +model/crdt_advanced.rs:82 +model/crdt_table.rs:92 +model/document.rs:95 +model/selection.rs:92 +model/session.rs:92 +model/style.rs:92 +plugins/mod.rs:90}" +TOOLCHAIN="$(sed -n 's/^channel = "\(.*\)"/\1/p' "$ROOT/rust-toolchain.toml")" +HOST_TRIPLE="${DOC_WS_COV_HOST:-x86_64-unknown-linux-gnu}" + +# Same TMPDIR reasoning as tools/test-spreadsheet-coverage.sh: a mktemp +# default under $HOME/.cache -- roomy, outside the workspace, and removed +# on every exit path. +DEFAULT_TMP="${HOME:-/var/tmp}/.cache/nigig-coverage" +mkdir -p "${TMPDIR:-$DEFAULT_TMP}" +WORK="$(mktemp -d "${TMPDIR:-$DEFAULT_TMP}/doc-workspace-coverage.XXXXXXXX")" +KEEP_COVERAGE="${KEEP_COVERAGE:-0}" + +cleanup() { + local status=$? + if [[ "$KEEP_COVERAGE" == "1" ]]; then + echo "coverage environment retained: $WORK" >&2 + else + rm -rf -- "$WORK" + rmdir "$DEFAULT_TMP" 2>/dev/null || true + rmdir "${HOME:-/var/tmp}/.cache" 2>/dev/null || true + echo "cleaned isolated coverage environment" >&2 + fi + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +DOC="$ROOT/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc" +MANIFEST="$ROOT/crates/apps/nigig-build/Cargo.toml" + +# Root-level pure files. Subdirectories are copied whole (their mod.rs +# is pure too), listed in PURE_DIRS below. Adding a new pure module at +# the root means adding it here, otherwise it is silently unmeasured -- +# the watchdog at the end of section 3 looks for exactly that drift. +ROOT_FILES=( + advanced_json.rs + crdt_bridge.rs + mobile_gesture.rs + persistence.rs + projection_layout.rs + projection_session.rs +) +PURE_DIRS=( + collaboration + editing + layout + model + plugins +) + +# --------------------------------------------------------------------------- +# 1. Isolated toolchain with the coverage instrumentation components +# --------------------------------------------------------------------------- +export RUSTUP_HOME="$WORK/rustup" +export CARGO_HOME="$WORK/cargo" +export CARGO_TARGET_DIR="$WORK/target" +export PATH="$CARGO_HOME/bin:$PATH" +export LLVM_PROFILE_FILE="$WORK/profiles/%p-%m.profraw" +export RUSTFLAGS="-C instrument-coverage -C codegen-units=1 -C opt-level=0" +mkdir -p "$WORK/profiles" + +curl --fail --location https://sh.rustup.rs -o "$WORK/rustup-init" +chmod 700 "$WORK/rustup-init" +"$WORK/rustup-init" -y --profile minimal --default-toolchain "$TOOLCHAIN" \ + --component llvm-tools-preview --no-modify-path + +# --------------------------------------------------------------------------- +# 2. makepad-math sources at the pinned rev (the only Makepad the pure +# files touch). Same sparse/blobless trick as tools/test-cad-coverage.sh: +# 29 MB and seconds instead of a 319 MB shallow clone. +# --------------------------------------------------------------------------- +MAKEPAD_REV="$(sed -n 's/.*makepad-widgets.*rev = "\([0-9a-f]\{40\}\)".*/\1/p' \ + "$MANIFEST" | head -1)" +[[ -n "$MAKEPAD_REV" ]] || { echo "cannot read the pinned makepad rev"; exit 1; } +MAKEPAD="$WORK/makepad" +git init --quiet "$MAKEPAD" +git -C "$MAKEPAD" remote add origin https://gitdab.com/andodeki/makepad +# The closure of makepad-math's path dependencies: math -> micro_serde -> +# live_id, each with a proc-macro sibling. +git -C "$MAKEPAD" sparse-checkout set --cone \ + libs/math libs/micro_serde libs/live_id libs/micro_proc_macro +git -C "$MAKEPAD" fetch --quiet --depth 1 --filter=blob:none origin "$MAKEPAD_REV" +git -C "$MAKEPAD" checkout --quiet FETCH_HEAD +for manifest in libs/math libs/micro_serde libs/micro_serde/derive \ + libs/micro_proc_macro libs/live_id libs/live_id/id_macros; do + test -f "$MAKEPAD/$manifest/Cargo.toml" \ + || { echo "missing $manifest in the Makepad checkout"; exit 1; } +done + +# --------------------------------------------------------------------------- +# 3. Assemble the host-only harness +# --------------------------------------------------------------------------- +HARNESS="$WORK/harness" +DEST="$HARNESS/src/construction_frame/pages/workspace/doc" +mkdir -p "$DEST" "$WORK/shim/src" + +cat > "$WORK/shim/Cargo.toml" < "$WORK/shim/src/lib.rs" <<'EOF' +//! Host-only stand-in for `makepad_widgets`. The pure doc files take +//! only the math types from Makepad (DVec2/Rect/Vec4f, dvec2/vec4), so +//! the shim re-exports makepad-math and nothing from the GUI or +//! platform layer -- which is what keeps this build headless. +pub use makepad_math::*; +EOF + +# doc-engine is a path dependency of the real crate and pure Rust, so +# the harness references a copy of the real thing (no lockfile of its +# own -- the workspace root owns the lock -- so it re-resolves serde, +# a two-crate registry hit). +mkdir -p "$WORK/doc-engine" +cp "$ROOT/crates/apps/doc/doc-engine/Cargo.toml" "$WORK/doc-engine/" +cp -r "$ROOT/crates/apps/doc/doc-engine/src" "$WORK/doc-engine/src" + +cat > "$HARNESS/Cargo.toml" < "$HARNESS/src/lib.rs" <<'EOF' +//! Coverage harness root. Scaffolding only -- excluded from the report. + +/// Stand-in for `nigig_core::dir`, which pulls in the platform layer +/// (the real `app_data_dir` resolves ProjectDirs). Tests point the data +/// dir at temp space anyway; fall back to the system temp dir. +pub mod dir { + use std::path::PathBuf; + pub fn app_data_dir() -> PathBuf { + std::env::var_os("NIGIG_DOC_COV_DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + } +} + +pub mod construction_frame { + pub mod pages { + pub mod workspace { + pub mod doc; + } + } +} +EOF + +# The pure sources, placed at the same relative path as in the crate. +for f in "${ROOT_FILES[@]}"; do + cp "$DOC/$f" "$DEST/$f" +done +for d in "${PURE_DIRS[@]}"; do + cp -r "$DOC/$d" "$DEST/$d" +done +cp "$DOC/tests_pure.rs" "$DEST/tests_pure.rs" + +# doc/mod.rs is widget-bound and cannot compile host-only, so the +# harness generates one that declares the pure subtree plus the test +# module. Every name comes from the file lists above, so the harness +# cannot drift away from the crate silently: a file removed from the +# crate fails the cp above, and a new pure file fails the watchdog below. +{ + echo "//! Pure-logic view of the doc module. Generated by" + echo "//! tools/test-doc-workspace-coverage.sh -- do not edit." + for d in "${PURE_DIRS[@]}"; do + echo "pub mod $d;" + done + for f in "${ROOT_FILES[@]}"; do + echo "pub mod ${f%.rs};" + done + echo "#[cfg(test)]" + echo "mod tests_pure;" +} > "$DEST/mod.rs" + +# Watchdog: a pure file in the crate that this harness does not measure. +is_pure() { + ! grep -qE 'live_design!|impl Widget|&mut Cx|Live, LiveHook|use makepad_widgets::\*' "$1" +} +for f in "$DOC"/*.rs; do + base="$(basename "$f")" + case "$base" in + mod.rs|tests.rs|tests_pure.rs) continue ;; + esac + case " ${ROOT_FILES[*]} " in + *" $base "*) continue ;; + esac + if is_pure "$f"; then + echo "NOTE: $base has no widget markers but is not in ROOT_FILES." >&2 + echo " If it is pure, add it so it gets measured." >&2 + fi +done + +# --------------------------------------------------------------------------- +# 4. Instrumented run: the copied pure suite (unit tests inside the +# sources' #[cfg(test)] blocks plus tests_pure.rs) +# --------------------------------------------------------------------------- +cargo test --manifest-path "$HARNESS/Cargo.toml" --lib + +# --------------------------------------------------------------------------- +# 5. Report +# --------------------------------------------------------------------------- +LLVM_BIN="$RUSTUP_HOME/toolchains/$TOOLCHAIN-$HOST_TRIPLE/lib/rustlib/$HOST_TRIPLE/bin" +"$LLVM_BIN/llvm-profdata" merge -sparse "$WORK/profiles"/*.profraw \ + -o "$WORK/coverage.profdata" + +mapfile -t BINS < <(find "$CARGO_TARGET_DIR/debug/deps" -maxdepth 1 -type f -executable \ + -name 'nigig_build-*' ! -name '*.d') +[[ ${#BINS[@]} -gt 0 ]] || { echo "no instrumented test binaries found"; exit 1; } +OBJECTS=("${BINS[0]}") +for b in "${BINS[@]:1}"; do OBJECTS+=(-object "$b"); done + +# Two independent exclusions (same reasoning as the CAD harness): +MAKEPAD_ESC="$(printf '%s' "$MAKEPAD" | sed 's/[][\.^$*+?(){}|\/]/\\&/g')" +IGNORE="(/cargo/registry|/cargo/git|/rustc/|$MAKEPAD_ESC|/shim/|harness/src/lib\.rs)" + +SOURCES=() +for f in "${ROOT_FILES[@]}"; do + SOURCES+=("$DEST/$f") +done +for d in "${PURE_DIRS[@]}"; do + while IFS= read -r f; do SOURCES+=("$f"); done < <(find "$DEST/$d" -name '*.rs' | sort) +done +SOURCES+=("$DEST/tests_pure.rs") + +"$LLVM_BIN/llvm-cov" report "${OBJECTS[@]}" \ + -instr-profile="$WORK/coverage.profdata" -ignore-filename-regex="$IGNORE" \ + "${SOURCES[@]}" + +if [[ "$KEEP_COVERAGE" == "1" ]]; then + "$LLVM_BIN/llvm-cov" show "${OBJECTS[@]}" \ + -instr-profile="$WORK/coverage.profdata" -ignore-filename-regex="$IGNORE" \ + "${SOURCES[@]}" \ + | grep -E '^ *[0-9]+\| *0\|' > "$WORK/uncovered-lines.txt" || true + echo "uncovered line report: $WORK/uncovered-lines.txt" >&2 +fi + +# --------------------------------------------------------------------------- +# 6. Enforce the floors +# --------------------------------------------------------------------------- +"$LLVM_BIN/llvm-cov" export "${OBJECTS[@]}" \ + -instr-profile="$WORK/coverage.profdata" -ignore-filename-regex="$IGNORE" \ + "${SOURCES[@]}" > "$WORK/coverage.json" + +if [[ "${DOC_WS_COVERAGE_REPORT_ONLY:-0}" == "1" ]]; then + echo 'report-only mode: the floors were not enforced' + exit 0 +fi + +python3 - "$WORK/coverage.json" "$TOTAL_FLOOR" "$PER_FILE_FLOORS" <<'PY' +import json, sys + +path, total_floor, per_file = sys.argv[1], float(sys.argv[2]), sys.argv[3] +with open(path) as fh: + data = json.load(fh) +export = data["data"][0] +total = export["totals"]["lines"]["percent"] +measured = {f["filename"]: f["summary"]["lines"]["percent"] for f in export["files"]} + +failures = [] +if total < total_floor: + failures.append(f" total {total:.2f}% is below the floor of {total_floor:.2f}%") + +for line in per_file.split(): + if not line.strip(): + continue + name, _, floor = line.rpartition(":") + floor = float(floor) + hits = [v for k, v in measured.items() if k.endswith("/doc/" + name)] + if not hits: + failures.append( + f" {name} has a floor but was not measured -- was it renamed, " + "deleted, or dropped from ROOT_FILES? A floor on a file that " + "is not measured silently protects nothing.") + continue + if hits[0] < floor: + failures.append( + f" {name} {hits[0]:.2f}% is below its floor of {floor:.2f}%") + +if failures: + print("coverage floors not met:", file=sys.stderr) + print("\n".join(failures), file=sys.stderr) + print( + "\nEither the change removed tested code, or it added untested code.\n" + "Lowering a floor is a reviewable edit to tools/test-doc-workspace-coverage.sh,\n" + "not something to do quietly.", + file=sys.stderr, + ) + sys.exit(1) + +print(f"all coverage floors met (total {total:.2f}%)") +PY