diff --git a/.forgejo/workflows/nigig-build.yml b/.forgejo/workflows/nigig-build.yml index 46fd5f7..75d5858 100644 --- a/.forgejo/workflows/nigig-build.yml +++ b/.forgejo/workflows/nigig-build.yml @@ -22,7 +22,6 @@ 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' @@ -36,7 +35,6 @@ 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' @@ -512,33 +510,3 @@ 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 deleted file mode 100644 index 55d969d..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/COVERAGE.md +++ /dev/null @@ -1,99 +0,0 @@ -# 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 d5efdba..372384d 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,42 +1543,3 @@ 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 9b3c9ae..1211895 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,17 +445,6 @@ 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(); @@ -466,6 +455,15 @@ 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 bd2cb6e..484d5ad 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,5 +203,3 @@ 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 aca79e0..5be3b7c 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,6 +115,29 @@ 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 0ba2dd8..8e87a32 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,6 +6,738 @@ //! 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 @@ -26,20 +758,6 @@ 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 { @@ -4656,6 +5374,7 @@ 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. @@ -4796,3 +5515,79 @@ 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 deleted file mode 100644 index 903325a..0000000 --- a/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc/tests_pure.rs +++ /dev/null @@ -1,4251 +0,0 @@ -//! 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 deleted file mode 100755 index 1906b3c..0000000 --- a/tools/test-doc-workspace-coverage.sh +++ /dev/null @@ -1,404 +0,0 @@ -#!/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