Compare commits

...

3 commits

Author SHA1 Message Date
arena-agent
b478945c34 ci(doc): gate the doc-workspace coverage floor on every push
Some checks failed
email.yml / ci(doc): gate the doc-workspace coverage floor on every push (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
Adds the doc-workspace-coverage job to the nigig-build workflow,
mirroring the CAD gate: checkout, then
./tools/test-doc-workspace-coverage.sh, which installs its own
instrumented toolchain into a shell-trap-cleaned temp dir and fails
if the total floor (92%) or any per-file floor is not met. The script
joins the workflow's push/PR path filters next to
tools/test-cad-coverage.sh so edits to the harness itself re-run the
gate. The doc README gains the milestone section recording the
28.55% -> 96.76% line measurement, the honest exclusions (widget
layer, persistence write-path wrappers, defensive traversal guards)
and the behavior pins and defect fixes the drive surfaced.
2026-08-17 10:25:27 +00:00
arena-agent
949cf24189 test(doc): doc-workspace coverage harness with enforced floors
tools/test-doc-workspace-coverage.sh measures line+region coverage of
the doc module's pure layer the same way the CAD gate does: it copies
the dependency-free sources (advanced_json, crdt_bridge,
mobile_gesture, persistence, projection_layout, projection_session,
and the collaboration/, editing/, layout/, model/, plugins/ trees)
plus tests_pure.rs into a temporary host-only crate with the real
module path, satisfies the five makepad-math symbols the pure layer
uses through a 30-line makepad-widgets shim, runs the suite under
-C instrument-coverage with a toolchain it installs itself, and
enforces a total floor plus a per-file floor for every instrumented
file. The per-file floors are the point: a lone total waves through
the silent loss of one whole file's tests.

Measurement moved from a 28.55% line baseline to 96.76% (6170 lines)
with the tranche in the parent commit; floors sit a few points under
per file, except persistence.rs (55%), whose three write-path entry
points write into the host's real application-data directory and are
covered through their path-injected seams instead -- the honest
exclusions, the exact table, and the two defect fixes this drive
surfaced (ReplaceBlockRange validation order, dead
RgaText::visit_children) are written down in the module's new
COVERAGE.md.

Everything the script touches -- pinned toolchain, cargo home, target
dir, fetched Makepad tree, profraw data -- lives under one mktemp dir
removed by a shell trap on every exit path; nothing lands in the repo
or $HOME unless KEEP_COVERAGE=1 is set for a debugging run.
DOC_WS_COVERAGE_REPORT_ONLY=1 measures without gating.
2026-08-17 10:25:27 +00:00
arena-agent
71c31cd19f test(doc): host-only suite split + pure-layer coverage tranche
Split the doc module's tests in two so the dependency-free majority
can also run under coverage instrumentation on a host-only crate:

* tests_pure.rs (new) holds every test that needs no Cx -- model,
  layout, editing, collaboration, advanced JSON, projection
  layout/session, CRDT bridge, persistence seams, mobile gestures --
  and is the file tools/test-doc-workspace-coverage.sh copies
  byte-for-byte into its harness.
* tests.rs keeps the widget-runtime and boot tests; shared helpers
  (projection_table_engine, only_table) live in tests_pure so both
  suites use them.

On top of the split, this tranche adds ~80 tests covering the pure
layer's real gaps: every Command apply arm and its inverse (text,
atoms, blocks by stable id, image properties, block ranges, table
cells/rows/columns, merges/splits/restores, node insert/delete/
replace), the DocumentController's CRDT typing lifecycle
(insert/replace/delete ranges, backspace/forward delete, position
sync), remote operation classification
(Applied/Duplicate/Deferred/Rejected), tombstone compaction at a
peer-acknowledged frontier, a two-peer MemoryTransport conversation,
typing coalescing and history limits, the cell-text editing and
multi-line geometry helpers in projection_layout, table cell
cursor/range/merge queries, and the small model/session/selection
behaviour surface.

Two defects found while writing the tests, fixed with pins:

* Command::ReplaceBlockRange validated a caller-supplied block_ids
  length AFTER draining blocks and legacy ids out of the document, so
  a malformed (remote) command destroyed content before reporting
  failure. Validation now runs before any mutation; a test proves a
  rejected replace leaves blocks, ids and order untouched.
* model::crdt::RgaText::visit_children was dead code -- a
  String-collecting duplicate of visit_atoms with no callers. Removed.

Two semantics that were folklore are now pinned with inline
reasoning: a multi-peer conversation only converges when each peer
owns a distinct document.crdt.local_actor (the two-controller sync
test assigns alice/bob), and a mid-range replace_range_crdt renders
its replacement after the tombstoned subtree it replaced, because
RGA sibling order walks by atom id ("hello" -> "hloY" is asserted,
not assumed).

cargo test -p nigig-build --lib: 1031 passed, 0 failed.
2026-08-17 10:25:27 +00:00
9 changed files with 4852 additions and 841 deletions

View file

@ -22,6 +22,7 @@ on:
- 'crates/nigig-uikit/**'
- 'crates/matrix_client/**'
- 'tools/test-cad-coverage.sh'
- 'tools/test-doc-workspace-coverage.sh'
- 'Cargo.lock'
- 'Cargo.toml'
- 'rust-toolchain.toml'
@ -35,6 +36,7 @@ on:
- 'crates/nigig-uikit/**'
- 'crates/matrix_client/**'
- 'tools/test-cad-coverage.sh'
- 'tools/test-doc-workspace-coverage.sh'
- 'Cargo.lock'
- 'Cargo.toml'
- 'rust-toolchain.toml'
@ -510,3 +512,33 @@ jobs:
- name: Engine coverage, with floors
run: ./tools/test-cad-coverage.sh
# ---------------------------------------------------------------------
# Source coverage for the document workspace's pure layer.
#
# Same shape as the CAD gate above: tools/test-doc-workspace-coverage.sh
# copies the doc module's dependency-free sources (model, layout,
# editing, collaboration, advanced JSON, CRDT bridge, projection layout/
# session, mobile gestures, persistence seams) plus tests_pure.rs into a
# host-only crate with a makepad-math shim, runs them under
# -C instrument-coverage, and enforces a total floor plus a per-file
# floor for every instrumented file. Everything -- toolchain, cargo
# home, target dir, fetched Makepad tree, profraw data -- lives in a
# mktemp dir removed by a shell trap on every exit path.
#
# This does NOT cover the widget layer (mod.rs, crdt_widget.rs,
# widgets/, render/, projection_renderer.rs): those need live_design!,
# Cx and an event loop, and are gated by the full-crate build and test
# jobs above. persistence.rs keeps a lower floor on purpose: three
# write-path entry points save into the host's real application-data
# directory and are covered only through their path-injected seams
# (save_doc_state_to / load_saved_doc_state_with); see the doc module's
# COVERAGE.md for the honest exclusion list.
doc-workspace-coverage:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Doc workspace coverage, with floors
run: ./tools/test-doc-workspace-coverage.sh

View file

@ -0,0 +1,99 @@
# Doc-workspace source coverage
`tools/test-doc-workspace-coverage.sh` measures line/region coverage for the
**pure** (widget-free) part of this module and enforces floors in CI
(`.forgejo/workflows/nigig-build.yml`, job `doc-workspace-coverage`). The same
suite also runs uninstrumented inside `cargo test -p nigig-build --lib`, so
every test executes twice: once in the real crate and once in the harness.
## How it works
The script copies the dependency-free sources — `advanced_json.rs`,
`crdt_bridge.rs`, `mobile_gesture.rs`, `persistence.rs`,
`projection_layout.rs`, `projection_session.rs`, and the `collaboration/`,
`editing/`, `layout/`, `model/`, `plugins/` trees — plus `tests_pure.rs`
byte-for-byte into a temporary host-only crate with the real module path. A 30
line `makepad-widgets` shim (re-exporting `makepad_math` plus the `log!` /
`error!` macros) satisfies the only Makepad symbols the pure layer uses
(`DVec2`, `Rect`, `Vec4f`, `dvec2`, `vec4`).
`doc-engine` is included as a path dependency because the pure projection code
sits on top of it.
The harness then installs its own pinned toolchain with
`llvm-tools-preview` into the same temp dir, runs the suite under
`-C instrument-coverage`, and enforces:
* a **total** line floor (`DOC_WS_COVERAGE_TOTAL_FLOOR`), and
* a **per-file** floor for every instrumented source
(`DOC_WS_COVERAGE_PER_FILE_FLOORS`).
The per-file floors are the point: deleting one file's whole test section
moves the total by a point or two and a lone number would wave that through.
Lowering a floor is a reviewable edit to the script, not something to do
quietly. Everything — toolchain, cargo home, target dir, fetched Makepad
tree, profraw data — is removed by a shell trap on every exit path; nothing
is written into the repository or `$HOME` unless `KEEP_COVERAGE=1` is set.
## Running it
```sh
./tools/test-doc-workspace-coverage.sh # gated run (CI)
DOC_WS_COVERAGE_REPORT_ONLY=1 ./tools/test-doc-workspace-coverage.sh # measure only
KEEP_COVERAGE=1 ./tools/test-doc-workspace-coverage.sh # keep uncovered-lines.txt + report
```
## Latest measurement (2026-08-17)
| File | Lines covered |
| --- | --- |
| TOTAL | **96.76%** (6170 lines) |
| advanced_json.rs | 98.02% |
| crdt_bridge.rs | 95.45% |
| mobile_gesture.rs | 100% |
| persistence.rs | 65.85% (see exclusions) |
| projection_layout.rs | 98.52% |
| projection_session.rs | 100% |
| collaboration/* | 100% |
| editing/commands.rs | 93.39% |
| editing/controller.rs | 93.39% |
| editing/history.rs | 95.65% |
| layout/* | 94100% |
| model/* | 91100% |
| 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.

View file

@ -1543,3 +1543,42 @@ end handle, drag onto 'w' in "world"): no request mid-drag, a fresh
request on lift-off whose rect is wider than the stale one, focus
landed on the dragged-to atom; the View-mode twin asserts the span
adjusts but `clipboard_menu` stays empty.
## Doc-workspace coverage gate (pure layer at 96.76% lines)
The doc module had exactly the problem the CAD and doc-engine gates
were built for: a host-only-testable core that had never been measured
because `cargo test -p nigig-build` links wayland/X11/GL/alsa/polkit.
The test suite is now split in two — `tests_pure.rs` holds every test
that needs no `Cx` (model, layout, editing, collaboration,
advanced JSON, projection layout/session, CRDT bridge, persistence
seams, mobile gestures), and `tests.rs` keeps the widget-runtime and
boot tests. `tools/test-doc-workspace-coverage.sh` then copies the
pure sources plus `tests_pure.rs` into a temporary host-only crate
with a makepad-math shim (the same shape as the CAD gate), runs them
under `-C instrument-coverage`, and enforces a total floor plus a
per-file floor for every instrumented file. Baseline was **28.55%**
lines; the gate now holds **96.76%** with floors a few points under
per file (persistence keeps a documented lower floor — see
`COVERAGE.md` for the honest exclusion list and exact numbers). A new
`doc-workspace-coverage` CI job runs the script on every push that
touches the crate, and the script self-reports any pure file that
appears without a floor so the classification cannot silently rot.
Growing the suite sat on the roadmap long enough that the exercise
also surfaced real behavior worth pinning, and two outright defects
that are now fixed: `Command::ReplaceBlockRange` used to validate its
explicit `block_ids` length after draining blocks out of the document
(a malformed remote command destroyed content before failing), and
`RgaText::visit_children` was a dead String-collecting duplicate of
`visit_atoms` (removed). The controller tests also nail two semantics
that were previously only folklore: remote typing between two
`DocumentController`s only converges when each peer owns a distinct
`document.crdt.local_actor` (the sync test assigns `alice`/`bob`),
and a mid-range `replace_range_crdt` renders its replacement after
the tombstoned subtree it replaced, because RGA siblings walk in id
order (`"hello" -> "hloY"` is pinned with the reasoning inline). The
baseline, the per-file floors, and what intentionally stays outside
the measurement (the whole widget layer, the persistence write-path
wrappers, the defensive traversal guards) live in `COVERAGE.md`; the
harness writes nothing outside a shell-trap-cleaned mktemp dir.

View file

@ -445,6 +445,17 @@ impl Command {
if index > document.blocks.len() || index + remove_count > document.blocks.len() {
return None;
}
let inserted = replacement.len();
// Validate BEFORE mutating: a mismatched explicit id list must
// not drain blocks out of the document before this command
// reports failure.
let ids = match block_ids {
Some(ids) if ids.len() == inserted => ids,
Some(_) => return None,
None => (0..inserted)
.map(|_| BlockId(document.crdt.next_atom_id()))
.collect(),
};
document.ensure_legacy_block_ids();
let previous: Vec<DocBlock> =
document.blocks.drain(index..index + remove_count).collect();
@ -455,15 +466,6 @@ impl Command {
for id in &previous_ids {
document.block_order.tombstone(id);
}
let inserted = replacement.len();
let ids = block_ids.unwrap_or_else(|| {
(0..inserted)
.map(|_| BlockId(document.crdt.next_atom_id()))
.collect()
});
if ids.len() != inserted {
return None;
}
let after = if index == 0 {
None
} else {

View file

@ -203,3 +203,5 @@ script_mod! {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_pure;

View file

@ -115,29 +115,6 @@ impl RgaText {
self.visit_atoms(Some(&atom.id), out, seen);
}
}
fn visit_children(
&self,
after: Option<&AtomId>,
out: &mut String,
seen: &mut BTreeSet<AtomId>,
) {
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

View file

@ -6,738 +6,6 @@
//! nothing and only broke the test build; it has been removed. The
//! canonical implementation lives in `layout/layout_engine.rs`.
use super::collaboration::*;
use super::crdt_bridge::CrdtProjectionBridge;
use super::layout::*;
use super::model::*;
#[test]
fn page_cache_invalidates_following_pages() {
let mut cache = PageLayoutCache::default();
for index in 0..3 {
cache.put(LayoutPage {
index,
rect: makepad_widgets::Rect::default(),
content_rect: makepad_widgets::Rect::default(),
});
}
cache.invalidate_from(1);
assert_eq!(cache.len(), 1);
assert!(cache.get(0).is_some());
assert!(cache.get(1).is_none());
}
#[test]
#[ignore = "manual performance benchmark"]
fn benchmark_long_document_paragraph_layout() {
use std::time::Instant;
let spans = vec![StyleSpan {
text: "incremental layout benchmark text ".repeat(80),
font_size: 12.0,
..Default::default()
}];
let request = ParagraphLayoutRequest {
block_idx: 0,
spans: &spans,
origin: makepad_widgets::dvec2(0.0, 0.0),
available_width: 600.0,
align: DocAlign::Left,
default_font_size: 12.0,
line_spacing: 1.4,
};
let start = Instant::now();
for _ in 0..1_000 {
let _ = layout_paragraph(request.clone(), |_style, text| {
text.chars().count() as f64 * 6.0
});
}
eprintln!("paragraph layout benchmark: {:?}", start.elapsed());
}
#[test]
fn memory_transport_transfers_ack_messages() {
let mut transport = MemoryTransport::default();
transport.send_ack(AckMessage {
actor: "peer".into(),
counter: 12,
});
let wire: Vec<_> = transport.outgoing_acks.drain(..).collect();
transport.incoming_acks.extend(wire);
let received = transport.receive_acks();
assert_eq!(received[0].actor, "peer");
assert_eq!(received[0].counter, 12);
}
#[test]
fn acknowledgement_frontier_uses_slowest_peer() {
let mut session = CollaborationSession::new("local");
session.acknowledge_frontier("a".into(), 10);
session.acknowledge_frontier("b".into(), 6);
session.acknowledge_frontier("a".into(), 12);
assert_eq!(session.safe_frontier(), Some(6));
}
#[test]
fn crdt_table_cells_are_addressed_by_stable_row_and_column_ids() {
let row = TableRowId(AtomId {
actor: "a".into(),
sequence: 1,
});
let column = TableColumnId(AtomId {
actor: "a".into(),
sequence: 2,
});
let cell = CrdtTableCell {
id: TableCellId(AtomId {
actor: "a".into(),
sequence: 3,
}),
row: row.clone(),
column: column.clone(),
row_span: 1,
col_span: 1,
children: Vec::new(),
deleted: false,
};
let table = CrdtTable {
rows: vec![row.clone()],
columns: vec![column.clone()],
cells: vec![cell],
};
assert!(table.cell(&row, &column).is_some());
}
#[test]
fn crdt_advanced_tree_filters_deleted_nodes() {
let id = AdvancedNodeId(AtomId {
actor: "a".into(),
sequence: 1,
});
let node = DocumentNode {
id: 1,
style: BlockStyle::default(),
kind: BlockKind::Divider,
};
let tree = CrdtAdvancedTree {
nodes: vec![CrdtAdvancedNode {
id: id.clone(),
parent: None,
order_after: None,
node,
deleted: false,
}],
};
assert_eq!(tree.visible_children(None).len(), 1);
}
#[test]
fn crdt_projection_bridge_materializes_text_and_style() {
let mut engine = doc_engine::controller::DocumentController::default();
let block = engine.insert_block("a", None, "paragraph").unwrap();
engine.insert_text("a", block.clone(), None, "bridge");
engine.set_text_style_at_offsets(
"a",
block,
0,
6,
doc_engine::projection::TextStylePatch {
bold: Some(true),
..Default::default()
},
);
let mut document = Document::default();
CrdtProjectionBridge::apply(&engine.projection, &mut document);
match &document.blocks[0] {
DocBlock::Paragraph { spans, .. } => {
assert_eq!(spans[0].text, "bridge");
assert!(spans[0].bold);
}
_ => panic!(),
}
}
#[test]
fn crdt_projection_bridge_materializes_table() {
let mut engine = doc_engine::controller::DocumentController::default();
let table = engine.insert_table("a", None).unwrap();
let row = engine.insert_table_row("a", table.clone(), None).unwrap();
let col = engine
.insert_table_column("a", table.clone(), None)
.unwrap();
engine.set_table_cell("a", table, row, col, "cell");
let mut document = Document::default();
CrdtProjectionBridge::apply(&engine.projection, &mut document);
match &document.blocks[0] {
DocBlock::Table { cells, .. } => assert_eq!(cells[0][0].text, "cell"),
_ => panic!(),
}
}
#[test]
fn crdt_projection_bridge_materializes_inline_advanced_node_ref() {
let mut engine = doc_engine::controller::DocumentController::default();
engine.insert_node("a", None, None, "canvas", "{}");
let mut document = Document::default();
CrdtProjectionBridge::apply(&engine.projection, &mut document);
assert!(matches!(
document.blocks[0],
DocBlock::AdvancedNodeRef { .. }
));
assert_eq!(document.nodes.len(), 1);
}
#[test]
fn crdt_bridge_preserves_unified_block_node_order() {
let mut engine = doc_engine::controller::DocumentController::default();
let block = engine.insert_block("a", None, "paragraph").unwrap();
engine.insert_text("a", block.clone(), None, "before");
engine.insert_node("a", None, Some(block), "canvas", "{}");
let mut document = Document::default();
CrdtProjectionBridge::apply(&engine.projection, &mut document);
assert!(matches!(document.blocks[0], DocBlock::Paragraph { .. }));
assert!(matches!(
document.blocks[1],
DocBlock::AdvancedNodeRef { .. }
));
}
#[test]
fn crdt_native_vertical_slice() {
let mut engine = doc_engine::controller::DocumentController::default();
let block = engine.insert_block("test", None, "paragraph").unwrap();
engine.insert_text("test", block.clone(), None, "hi");
let layout =
crate::construction_frame::pages::workspace::doc::projection_layout::layout_projection(
&engine.projection,
);
assert_eq!(engine.projection.blocks[0].text, "hi");
let atom = layout.glyphs[0].atom.clone();
engine.insert_text("test", block.clone(), Some(atom), "!");
assert_eq!(engine.projection.blocks[0].text, "h!i");
assert!(engine.undo("test"));
assert_eq!(engine.projection.blocks[0].text, "hi");
let json = engine.document.to_json().unwrap();
let restored = doc_engine::crdt::CrdtDocument::from_json(&json).unwrap();
assert_eq!(restored.materialize().blocks[0].text, "hi");
}
use super::projection_layout::{
block_glyph_offset, cell_char_offset_at, cell_text_line_col, cell_text_line_count,
cell_text_line_spans, cell_text_offset_at, cell_text_origin_y, cell_text_replace_range,
cell_text_span_rects, glyph_index_of, layout_projection, parse_op_id, projected_node_metrics,
projected_stats, selection_handles, step_glyph, word_atom_range, ProjectedTableLayout,
ADVANCED_NODE_BOTTOM_GAP, ADVANCED_NODE_WIDTH, LAYOUT_MARGIN, LINE_HEIGHT,
SELECTION_HANDLE_TOUCH_SLOP, TABLE_BOTTOM_GAP, TABLE_CELL_HEIGHT, TABLE_CELL_TEXT_INSET,
TABLE_CELL_TEXT_LINE_HEIGHT, TABLE_CELL_WIDTH, TEXT_CHAR_ADVANCE,
};
use super::projection_session::{crdt_engine_from_saved, crdt_save_wire, CRDT_SAVE_HEADER};
use doc_engine::controller::DocumentController as CrdtController;
use doc_engine::crdt::OpId;
use makepad_widgets::dvec2;
/// Builds a 2x2 projected table with text in every cell and returns the
/// engine plus the stable table/row/column ids for further operations.
fn projection_table_engine() -> (CrdtController, OpId, OpId, OpId, OpId, OpId) {
let mut engine = CrdtController::default();
let table = engine.insert_block("t", None, "table").unwrap();
let row0 = engine.insert_table_row("t", table.clone(), None).unwrap();
let row1 = engine
.insert_table_row("t", table.clone(), Some(row0.clone()))
.unwrap();
let col0 = engine
.insert_table_column("t", table.clone(), None)
.unwrap();
let col1 = engine
.insert_table_column("t", table.clone(), Some(col0.clone()))
.unwrap();
engine.set_table_cell("t", table.clone(), row0.clone(), col0.clone(), "qty");
engine.set_table_cell("t", table.clone(), row0.clone(), col1.clone(), "unit");
engine.set_table_cell("t", table.clone(), row1.clone(), col0.clone(), "12");
engine.set_table_cell("t", table.clone(), row1.clone(), col1.clone(), "bags");
(engine, table, row0, row1, col0, col1)
}
fn only_table(engine: &CrdtController) -> ProjectedTableLayout {
let tree = layout_projection(&engine.projection);
assert_eq!(tree.tables.len(), 1);
tree.tables.into_iter().next().unwrap()
}
#[test]
fn projection_layout_places_table_cells_on_a_fixed_grid() {
let (engine, table_id, ..) = projection_table_engine();
let table = only_table(&engine);
assert_eq!(
table.block_id,
format!("{}:{}", table_id.actor, table_id.counter)
);
assert_eq!((table.rows, table.cols), (2, 2));
assert_eq!(table.cells.len(), 4);
assert_eq!(table.rect.pos, dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN));
assert_eq!(
table.rect.size,
dvec2(2.0 * TABLE_CELL_WIDTH, 2.0 * TABLE_CELL_HEIGHT)
);
let top_left = table.cell(0, 0).unwrap();
assert_eq!(top_left.text, "qty");
assert_eq!(top_left.rect.pos, dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN));
assert_eq!(
table.cell(0, 1).unwrap().rect.pos,
dvec2(LAYOUT_MARGIN + TABLE_CELL_WIDTH, LAYOUT_MARGIN)
);
assert_eq!(
table.cell(1, 0).unwrap().rect.pos,
dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN + TABLE_CELL_HEIGHT)
);
assert_eq!(table.cell(1, 1).unwrap().text, "bags");
assert!(table.cells.iter().all(|cell| !cell.covered));
}
#[test]
fn projection_layout_merged_cell_spans_and_covers_its_range() {
let (mut engine, table, row0, _row1, col0, col1) = projection_table_engine();
// Merges use stable row/column ids, never positional indexes.
engine.merge_table_cells("t", table, row0.clone(), col0, row0, col1);
let table = only_table(&engine);
let anchor = table.cell(0, 0).unwrap();
assert_eq!((anchor.row_span, anchor.col_span), (1, 2));
assert_eq!(
anchor.rect.size,
dvec2(2.0 * TABLE_CELL_WIDTH, TABLE_CELL_HEIGHT)
);
assert_eq!(anchor.text, "qty");
let covered = table.cell(0, 1).unwrap();
assert!(covered.covered);
assert!(covered.text.is_empty());
// A point inside the covered region resolves to the merge anchor.
let inside_covered = dvec2(LAYOUT_MARGIN + 1.5 * TABLE_CELL_WIDTH, LAYOUT_MARGIN + 4.0);
assert_eq!(table.hit(inside_covered), Some((0, 0)));
}
#[test]
fn projection_layout_hit_test_maps_points_to_cell_coordinates() {
let (engine, ..) = projection_table_engine();
let tree = layout_projection(&engine.projection);
let cell_11 = dvec2(
LAYOUT_MARGIN + 1.5 * TABLE_CELL_WIDTH,
LAYOUT_MARGIN + 1.5 * TABLE_CELL_HEIGHT,
);
assert_eq!(tree.table_hit_test(cell_11), Some((0, 1, 1)));
let cell_00 = dvec2(LAYOUT_MARGIN + 1.0, LAYOUT_MARGIN + 1.0);
assert_eq!(tree.table_hit_test(cell_00), Some((0, 0, 0)));
// Below the table there is no cell.
let below = dvec2(
LAYOUT_MARGIN + 1.0,
LAYOUT_MARGIN + 2.0 * TABLE_CELL_HEIGHT + 40.0,
);
assert_eq!(tree.table_hit_test(below), None);
}
#[test]
fn projection_layout_advances_blocks_below_the_table() {
let (mut engine, ..) = projection_table_engine();
let paragraph = engine.insert_block("t", None, "paragraph").unwrap();
engine.insert_text("t", paragraph, None, "after");
let tree = layout_projection(&engine.projection);
let table = &tree.tables[0];
let table_bottom = table.rect.pos.y + table.rect.size.y;
// The paragraph is block 1; its origin must clear the table plus gap.
let paragraph_origin = tree.block_origins[1];
assert_eq!(paragraph_origin.y, table_bottom + TABLE_BOTTOM_GAP);
// Its glyphs share the shifted line, not the legacy fixed line step.
let first_glyph = tree
.glyphs
.iter()
.find(|glyph| glyph.rect.pos.y > table_bottom)
.unwrap();
assert_eq!(first_glyph.rect.pos.y, paragraph_origin.y);
}
#[test]
fn projection_layout_interleaves_advanced_nodes_in_unified_order() {
let mut engine = CrdtController::default();
let p1 = engine.insert_block("t", None, "paragraph").unwrap();
engine.insert_text("t", p1.clone(), None, "before");
// Anchoring the node after p1, and p2 after the node, interleaves them.
let canvas = engine
.insert_node("t", None, Some(p1.clone()), "canvas", "{}")
.unwrap();
let p2 = engine
.insert_block("t", Some(canvas.clone()), "paragraph")
.unwrap();
engine.insert_text("t", p2.clone(), None, "after");
let tree = layout_projection(&engine.projection);
assert_eq!(tree.nodes.len(), 1);
let node = &tree.nodes[0];
assert_eq!(node.kind, "canvas");
assert_eq!(node.node_id, format!("{}:{}", canvas.actor, canvas.counter));
// p1 holds the first line; the canvas placeholder sits right below it.
assert_eq!(
node.rect.pos,
dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN + LINE_HEIGHT)
);
assert_eq!(node.rect.size, dvec2(ADVANCED_NODE_WIDTH, 240.0));
assert_eq!(node.label, "Canvas");
assert!(node.interactive);
// p2's origin clears the node plus its bottom gap.
let p2_index = engine
.projection
.blocks
.iter()
.position(|block| block.id == format!("{}:{}", p2.actor, p2.counter))
.unwrap();
assert_eq!(
tree.block_origins[p2_index],
dvec2(
LAYOUT_MARGIN,
node.rect.pos.y + node.rect.size.y + ADVANCED_NODE_BOTTOM_GAP
)
);
// The "after" glyphs sit on p2's shifted line, not the legacy fixed step.
let last_glyph = tree.glyphs.last().unwrap();
assert_eq!(last_glyph.block, p2);
assert_eq!(last_glyph.rect.pos.y, tree.block_origins[p2_index].y);
}
#[test]
fn projection_layout_node_metrics_match_legacy_advanced_layout() {
assert_eq!(
projected_node_metrics("image"),
(220.0, "Image".to_string(), true)
);
assert_eq!(
projected_node_metrics("canvas"),
(240.0, "Canvas".to_string(), true)
);
assert_eq!(
projected_node_metrics("divider"),
(18.0, "Divider".to_string(), false)
);
assert_eq!(
projected_node_metrics("audio"),
(52.0, "Audio".to_string(), true)
);
assert_eq!(
projected_node_metrics("video"),
(180.0, "Video".to_string(), true)
);
assert_eq!(
projected_node_metrics("diagram"),
(160.0, "Diagram".to_string(), true)
);
assert_eq!(
projected_node_metrics("quote"),
(56.0, "Quote".to_string(), false)
);
assert_eq!(
projected_node_metrics("page_break"),
(36.0, "Page Break".to_string(), false)
);
// Unknown kinds follow the bridge's EmbeddedWidget mapping.
assert_eq!(
projected_node_metrics("weather-pill"),
(64.0, "Widget: weather-pill".to_string(), true)
);
}
#[test]
fn projection_layout_node_hit_test_resolves_points_to_nodes() {
let mut engine = CrdtController::default();
engine.insert_node("t", None, None, "image", "{}");
let tree = layout_projection(&engine.projection);
let inside = dvec2(LAYOUT_MARGIN + 40.0, LAYOUT_MARGIN + 40.0);
assert_eq!(tree.node_hit_test(inside), Some(0));
// Beyond the placeholder column there is no node.
let outside = dvec2(
LAYOUT_MARGIN + ADVANCED_NODE_WIDTH + 40.0,
LAYOUT_MARGIN + 1.0,
);
assert_eq!(tree.node_hit_test(outside), None);
}
#[test]
fn projection_layout_stacks_tables_nodes_and_text_in_one_column() {
let (mut engine, table, ..) = projection_table_engine();
engine.insert_node("t", None, Some(table), "image", "{}");
let tree = layout_projection(&engine.projection);
let table_layout = &tree.tables[0];
let node = &tree.nodes[0];
assert_eq!(node.kind, "image");
// The image placeholder starts below the table plus its gap.
assert_eq!(
node.rect.pos.y,
table_layout.rect.pos.y + table_layout.rect.size.y + TABLE_BOTTOM_GAP
);
}
/// Engine with one paragraph reading "the quick brown fox": word starts
/// are at chars 0/4/10/16 for `word_atom_range` assertions.
fn projection_word_engine() -> CrdtController {
let mut engine = CrdtController::default();
let block = engine.insert_block("t", None, "paragraph").unwrap();
engine.insert_text("t", block, None, "the quick brown fox");
engine
}
#[test]
fn word_atom_range_selects_the_whitespace_delimited_word() {
let engine = projection_word_engine();
let atoms = &engine.projection.blocks[0].runs[0].atoms;
// Any atom inside "quick" (chars 4..=8) selects the whole word.
for pivot in 4..=8 {
let (start, end) = word_atom_range(&engine.projection, &atoms[pivot]).unwrap();
assert_eq!(start, atoms[4]);
assert_eq!(end, atoms[8]);
}
}
#[test]
fn word_atom_range_picks_the_preceding_word_on_whitespace() {
let engine = projection_word_engine();
let atoms = &engine.projection.blocks[0].runs[0].atoms;
// The space after "quick" resolves to "quick", matching legacy
// word_bounds pivot behaviour.
let (start, end) = word_atom_range(&engine.projection, &atoms[9]).unwrap();
assert_eq!(start, atoms[4]);
assert_eq!(end, atoms[8]);
}
#[test]
fn word_atom_range_at_line_end_selects_the_last_word() {
let engine = projection_word_engine();
let atoms = &engine.projection.blocks[0].runs[0].atoms;
let last = atoms.len() - 1;
let (start, end) = word_atom_range(&engine.projection, &atoms[last]).unwrap();
assert_eq!(start, atoms[16]);
assert_eq!(end, atoms[18]);
}
#[test]
fn selection_handles_normalize_into_document_order() {
let mut engine = CrdtController::default();
let block = engine.insert_block("t", None, "paragraph").unwrap();
engine.insert_text("t", block, None, "abc");
let layout = layout_projection(&engine.projection);
let glyph = |index: usize| &layout.glyphs[index];
// A backwards drag (anchor on 'c', focus on 'a') still reports the
// start handle on 'a' and the end handle on 'c'.
let handles = selection_handles(&layout, &glyph(2).atom, &glyph(0).atom).unwrap();
assert_eq!(handles.start_atom, glyph(0).atom);
assert_eq!(handles.end_atom, glyph(2).atom);
assert_eq!(handles.start_rect.pos.x, glyph(0).rect.pos.x - 8.0);
assert_eq!(
handles.end_rect.pos.x,
glyph(2).rect.pos.x + glyph(2).rect.size.x - 2.0
);
// A collapsed selection (anchor == focus) shows a caret, not handles.
assert!(selection_handles(&layout, &glyph(1).atom, &glyph(1).atom).is_none());
}
#[test]
fn selection_handle_hit_test_uses_the_touch_slop() {
let mut engine = CrdtController::default();
let block = engine.insert_block("t", None, "paragraph").unwrap();
engine.insert_text("t", block, None, "abc");
let layout = layout_projection(&engine.projection);
let handles =
selection_handles(&layout, &layout.glyphs[0].atom, &layout.glyphs[2].atom).unwrap();
assert_eq!(
handles.handle_at(handles.start_rect.pos + dvec2(1.0, 1.0)),
Some(true)
);
assert_eq!(
handles.handle_at(handles.end_rect.pos + dvec2(1.0, 1.0)),
Some(false)
);
// Just outside the rect is still a hit, within the slop.
let slop = SELECTION_HANDLE_TOUCH_SLOP - 2.0;
let near = dvec2(
handles.start_rect.pos.x - slop,
handles.start_rect.pos.y - slop,
);
assert_eq!(handles.handle_at(near), Some(true));
// Well past the slop there is no handle.
let far = dvec2(
handles.start_rect.pos.x - 40.0,
handles.start_rect.pos.y - 40.0,
);
assert_eq!(handles.handle_at(far), None);
}
/// Two-block engine ("ab" / "cd") for glyph-stream stepping assertions.
fn projection_two_block_engine() -> CrdtController {
let mut engine = CrdtController::default();
let first = engine.insert_block("t", None, "paragraph").unwrap();
engine.insert_text("t", first.clone(), None, "ab");
let second = engine.insert_block("t", Some(first), "paragraph").unwrap();
engine.insert_text("t", second, None, "cd");
engine
}
#[test]
fn step_glyph_crosses_block_boundaries_in_unified_order() {
let engine = projection_two_block_engine();
let layout = layout_projection(&engine.projection);
// Stream is [a, b, c, d], blocks [ab] [cd]: stepping past 'b' lands on
// 'c' in the next block without block-aware special cases.
let b_atom = layout.glyphs[1].atom.clone();
let c_glyph = step_glyph(&layout, &b_atom, true).unwrap();
assert_eq!(c_glyph.atom, layout.glyphs[2].atom);
assert_ne!(c_glyph.block, layout.glyphs[1].block);
let back = step_glyph(&layout, &c_glyph.atom.clone(), false).unwrap();
assert_eq!(back.atom, b_atom);
// Document edges yield no glyph.
assert!(step_glyph(&layout, &layout.glyphs[0].atom, false).is_none());
assert!(step_glyph(&layout, &layout.glyphs[3].atom, true).is_none());
}
#[test]
fn block_glyph_offset_positions_atoms_inside_their_block() {
let engine = projection_two_block_engine();
let layout = layout_projection(&engine.projection);
let first_block = layout.glyphs[0].block.clone();
assert_eq!(
block_glyph_offset(&engine.projection, &first_block, &layout.glyphs[0].atom),
Some(0)
);
assert_eq!(
block_glyph_offset(&engine.projection, &first_block, &layout.glyphs[1].atom),
Some(1)
);
// 'c' lives in the second block: querying it against the first fails.
let second_block = layout.glyphs[2].block.clone();
assert_eq!(
block_glyph_offset(&engine.projection, &first_block, &layout.glyphs[2].atom),
None
);
assert_eq!(
block_glyph_offset(&engine.projection, &second_block, &layout.glyphs[2].atom),
Some(0)
);
assert_eq!(glyph_index_of(&layout, &layout.glyphs[2].atom), Some(2));
}
#[test]
fn parse_op_id_round_trips_and_rejects_garbage() {
let id = OpId {
actor: "peer-7".to_string(),
counter: 42,
};
let parsed = parse_op_id(&format!("{}:{}", id.actor, id.counter)).unwrap();
assert_eq!(parsed, id);
assert!(parse_op_id("no-counter").is_none());
assert!(parse_op_id("a:notanumber").is_none());
assert!(parse_op_id("").is_none());
}
/// Engine with styled text, an aligned paragraph, a populated table and an
/// image node — the full surface the workspace save button must preserve.
fn crdt_save_wire_engine() -> (CrdtController, OpId) {
let mut engine = CrdtController::default();
let block = engine.insert_block("t", None, "paragraph").unwrap();
engine.insert_text("t", block.clone(), None, "hello crdt world");
engine.toggle_text_style_at_offsets("t", block.clone(), 0, 5, "bold");
engine.set_block_alignment("t", block.clone(), "Center");
let table = engine.insert_table("t", Some(block.clone())).unwrap();
let row = engine.insert_table_row("t", table.clone(), None).unwrap();
let col = engine
.insert_table_column("t", table.clone(), None)
.unwrap();
engine.set_table_cell("t", table.clone(), row, col, "two words");
engine
.insert_node("t", None, Some(table), "image", "caption")
.unwrap();
(engine, block)
}
#[test]
fn crdt_save_wire_round_trips_engine_state() {
let (engine, block) = crdt_save_wire_engine();
let wire = crdt_save_wire(&engine.document).expect("wire serializes");
assert!(wire.starts_with(CRDT_SAVE_HEADER));
let restored = crdt_engine_from_saved(&wire).expect("wire parses");
// Same block sequence, text and alignment.
let ids: Vec<String> = engine
.projection
.blocks
.iter()
.map(|b| b.text.clone())
.collect();
let restored_ids: Vec<String> = restored
.projection
.blocks
.iter()
.map(|b| b.text.clone())
.collect();
assert_eq!(restored_ids, ids);
let id = format!("{}:{}", block.actor, block.counter);
let aligned = restored
.projection
.blocks
.iter()
.find(|b| b.id == id)
.unwrap();
assert_eq!(aligned.alignment, "Center");
// Styled runs survive the wire.
let original_runs = engine.projection.blocks[0].runs.len();
assert_eq!(aligned.runs.len(), original_runs);
assert!(aligned.runs.iter().any(|run| run.bold));
// Table cells and the advanced node survive too.
assert_eq!(restored.projection.tables.len(), 1);
let table = restored.projection.tables.values().next().unwrap();
assert!(table.cells.values().any(|cell| cell == "two words"));
assert_eq!(restored.projection.nodes.len(), 1);
assert_eq!(restored.projection.nodes[0].kind, "image");
assert_eq!(restored.projection.order, engine.projection.order);
}
#[test]
fn crdt_engine_from_saved_rejects_legacy_delimiter_and_garbage() {
// Legacy delimiter documents stay with the classic editor's migration.
assert!(
crdt_engine_from_saved("M|local|1|1\nP|Left|hello§false§false§false§12.0§~\n").is_none()
);
assert!(crdt_engine_from_saved("#MP_CRDT_V1\n{not json").is_none());
assert!(crdt_engine_from_saved("").is_none());
assert!(crdt_engine_from_saved("#MP_CRDT_V1\n").is_none());
}
#[test]
fn projected_stats_counts_words_and_chars_in_blocks_and_tables() {
let (engine, _) = crdt_save_wire_engine();
let (words, chars) = projected_stats(&engine.projection);
// "hello crdt world" (3 words, 16 chars) + table cell "two words"
// (2 words, 9 chars); the image node contributes nothing.
assert_eq!(words, 5);
assert_eq!(chars, 25);
let empty = CrdtController::default();
assert_eq!(projected_stats(&empty.projection), (0, 0));
}
#[test]
fn projection_layout_falls_back_to_block_order_when_order_is_empty() {
// A projection assembled without the operation log has no unified order.
let mut projection = doc_engine::projection::DocumentProjection::default();
for index in 0..2 {
projection
.blocks
.push(doc_engine::projection::ProjectedBlock {
id: format!("a:{}", index + 1),
kind: "paragraph".to_string(),
text: "x".to_string(),
..Default::default()
});
}
let tree = layout_projection(&projection);
assert_eq!(tree.block_origins[0], dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN));
assert_eq!(
tree.block_origins[1],
dvec2(LAYOUT_MARGIN, LAYOUT_MARGIN + LINE_HEIGHT)
);
assert!(tree.nodes.is_empty());
assert!(tree.tables.is_empty());
}
// == CrdtDocEditor runtime integration =====================================
// These tests run the real widget against a real `Cx` runtime and real
// `Event` values: the editor instance is built through the same
@ -758,6 +26,20 @@ fn projection_layout_falls_back_to_block_order_when_order_is_empty() {
use super::CrdtDocEditor;
use makepad_widgets::makepad_platform::makepad_script::{ScriptNew, ScriptVm, ScriptVmBase};
use makepad_widgets::{Cx, Event, KeyCode, KeyEvent, KeyModifiers, Scope, Widget};
// Shared with the pure-logic suite in tests_pure.rs: the projection
// helpers above the runtime harness section live there single-source.
use super::projection_layout::{
block_glyph_offset, cell_char_offset_at, cell_text_line_col, cell_text_line_count,
cell_text_line_spans, cell_text_offset_at, cell_text_origin_y, cell_text_replace_range,
cell_text_span_rects, layout_projection, parse_op_id, selection_handles, TABLE_BOTTOM_GAP,
TABLE_CELL_HEIGHT, TABLE_CELL_TEXT_INSET, TABLE_CELL_TEXT_LINE_HEIGHT, TABLE_CELL_WIDTH,
TEXT_CHAR_ADVANCE,
};
use super::projection_session::crdt_save_wire;
use super::tests_pure::{only_table, projection_table_engine};
use doc_engine::controller::DocumentController as CrdtController;
use doc_engine::crdt::OpId;
use makepad_widgets::dvec2;
fn runtime_key_down(code: KeyCode) -> Event {
Event::KeyDown(KeyEvent {
@ -5374,7 +4656,6 @@ fn layout_tree_cache_drops_on_engine_replacement() {
// precedence of the migrated persistence layer.
use super::crdt_widget::initial_document_source;
use super::persistence::{load_saved_doc_state_with, save_doc_state_to, GENERATED_DOC_FILE};
/// A factory-fresh editor with NO engine installed: the exact boot state
/// of the APK that opened blank.
@ -5515,79 +4796,3 @@ fn seed_demo_doc_builds_showcase_document() {
"table header row renders bold"
);
}
// ---- persistence store migration --------------------------------------
fn temp_store_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"nigig-doc-persistence-{}-{tag}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn saved_doc_round_trips_through_runtime_store() {
let store = temp_store_dir("round-trip");
save_doc_state_to(store.clone(), GENERATED_DOC_FILE, "{\"doc\":\"state\"}").expect("save");
assert_eq!(
load_saved_doc_state_with(store.clone(), store.join("no-manifest-here")),
Some("{\"doc\":\"state\"}".to_string())
);
let _ = std::fs::remove_dir_all(&store);
}
#[test]
fn runtime_store_takes_precedence_over_manifest_fallback() {
let store = temp_store_dir("store");
let manifest = temp_store_dir("manifest");
save_doc_state_to(store.clone(), GENERATED_DOC_FILE, "store version").expect("store save");
save_doc_state_to(manifest.clone(), GENERATED_DOC_FILE, "manifest version")
.expect("manifest save");
assert_eq!(
load_saved_doc_state_with(store.clone(), manifest.clone()),
Some("store version".to_string()),
"a document in the app-data store wins over the legacy source-tree copy"
);
let _ = std::fs::remove_dir_all(&store);
let _ = std::fs::remove_dir_all(&manifest);
}
#[test]
fn manifest_fallback_serves_legacy_saves_once() {
let store = temp_store_dir("empty-store");
let manifest = temp_store_dir("legacy-manifest");
// No file in the runtime store: the legacy source-tree copy is still
// honored (it is read-only now — new saves never go there).
save_doc_state_to(manifest.clone(), GENERATED_DOC_FILE, "legacy save").expect("manifest save");
assert_eq!(
load_saved_doc_state_with(store.clone(), manifest.clone()),
Some("legacy save".to_string())
);
let _ = std::fs::remove_dir_all(&store);
let _ = std::fs::remove_dir_all(&manifest);
}
#[test]
fn empty_or_missing_save_files_boot_fresh() {
let store = temp_store_dir("empty-file-store");
let manifest = temp_store_dir("missing-manifest");
save_doc_state_to(store.clone(), GENERATED_DOC_FILE, " \n").expect("save");
// An empty/whitespace save is treated as absent: the workspace boots
// fresh (demo seed) rather than feeding the loader a blank document.
assert_eq!(
load_saved_doc_state_with(store.clone(), manifest.clone()),
None
);
// And with nothing anywhere there is nothing to load.
let bare = temp_store_dir("bare");
assert_eq!(
load_saved_doc_state_with(bare.clone(), bare.join("none")),
None
);
let _ = std::fs::remove_dir_all(&store);
let _ = std::fs::remove_dir_all(&manifest);
let _ = std::fs::remove_dir_all(&bare);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,404 @@
#!/usr/bin/env bash
# Temporary LLVM source-coverage run for the doc workspace's pure logic.
#
# WHAT THIS COVERS
# ----------------
# The doc module (construction_frame/pages/workspace/doc) is ~20k lines,
# most of it widget code that needs live_design!, Cx and an event loop.
# But the module's CORE is dependency-free: the document model types,
# legacy layout engine pieces, collaboration glue, the projection
# layout tree (glyph/rect geometry, hit tests, selection handles), the
# projection session (save/load wire), advanced-node JSON, clipboard /
# style editing logic, persistence and the mobile gesture state machine.
# Those files import only the makepad-math types (DVec2/Rect/Vec4f,
# dvec2/vec4), doc-engine (pure Rust), serde and std. This harness
# copies them into a host-only crate that carries the SAME module path
# (`nigig_build::construction_frame::pages::workspace::doc::*`), so the
# sources compile byte-for-byte with no edits, no GUI, no windowing
# system and no platform startup — the CAD harness pattern, applied to
# the doc surface.
#
# The test driver is the crate's own tests_pure.rs: the doc test suite
# was split so that every dependency-free test lives there (widget
# runtime tests stay in tests.rs). The harness copies it byte-for-byte
# as `#[cfg(test)] mod tests_pure`, so the measurement is exactly the
# pure suite the lib run executes — single-sourced, no drift.
#
# WHAT IT EXCLUDES FROM THE REPORT (step 4 of the coverage plan)
# - the Makepad checkout -- generated/vendored upstream code
# - the cargo registry and git dirs -- third-party code
# - the rustc sysroot -- std
# - harness/src/lib.rs, shim/ -- the platform-startup stand-ins
# this script writes itself; scaffolding, not doc code, and counting
# it would flatter the number for no reason.
# Widget files (crdt_widget.rs, widgets/, render/,
# projection_renderer.rs, mod.rs) stay unmeasured here on purpose: they
# are gated by the crate's own lib suite in CI, and this script does not
# pretend to measure them.
#
# USAGE
# ./tools/test-doc-workspace-coverage.sh # run, enforce floors, clean up
# KEEP_COVERAGE=1 ./tools/test-doc-workspace-coverage.sh # keep env + uncovered lines
# DOC_WS_COVERAGE_REPORT_ONLY=1 ./tools/test-doc-workspace-coverage.sh # measure, don't gate
#
# Everything -- toolchain, cargo home, target dir, profraw data, the
# fetched Makepad tree and the report -- lives under a single mktemp
# directory a shell trap removes on success, failure, interrupt or
# termination. Nothing is written into the repository or $HOME.
set -Eeuo pipefail
IFS=$'\n\t'
ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
# Floors, set a couple of points under today's measurement so ordinary
# refactoring does not trip them while a real loss of coverage does.
# A single total hides the failure this is meant to catch: losing every
# test in one file moves the total by a point or two and a lone number
# would wave that through. Lowering a floor is a reviewable edit here,
# not something to do quietly.
TOTAL_FLOOR="${DOC_WS_COVERAGE_TOTAL_FLOOR:-92}"
PER_FILE_FLOORS="${DOC_WS_COVERAGE_PER_FILE_FLOORS:-\
projection_layout.rs:95
projection_session.rs:95
mobile_gesture.rs:95
persistence.rs:55
advanced_json.rs:94
crdt_bridge.rs:91
collaboration/session.rs:95
collaboration/transport.rs:92
editing/commands.rs:89
editing/controller.rs:89
editing/history.rs:91
layout/advanced_layout.rs:90
layout/block_cache.rs:90
layout/block_layout.rs:90
layout/divider_layout.rs:90
layout/hit_test.rs:90
layout/image_layout.rs:90
layout/layout_engine.rs:90
layout/layout_tree.rs:90
layout/mod.rs:90
layout/page_cache.rs:90
layout/page_layout.rs:90
layout/table_layout.rs:90
model/advanced.rs:92
model/crdt.rs:93
model/crdt_advanced.rs:82
model/crdt_table.rs:92
model/document.rs:95
model/selection.rs:92
model/session.rs:92
model/style.rs:92
plugins/mod.rs:90}"
TOOLCHAIN="$(sed -n 's/^channel = "\(.*\)"/\1/p' "$ROOT/rust-toolchain.toml")"
HOST_TRIPLE="${DOC_WS_COV_HOST:-x86_64-unknown-linux-gnu}"
# Same TMPDIR reasoning as tools/test-spreadsheet-coverage.sh: a mktemp
# default under $HOME/.cache -- roomy, outside the workspace, and removed
# on every exit path.
DEFAULT_TMP="${HOME:-/var/tmp}/.cache/nigig-coverage"
mkdir -p "${TMPDIR:-$DEFAULT_TMP}"
WORK="$(mktemp -d "${TMPDIR:-$DEFAULT_TMP}/doc-workspace-coverage.XXXXXXXX")"
KEEP_COVERAGE="${KEEP_COVERAGE:-0}"
cleanup() {
local status=$?
if [[ "$KEEP_COVERAGE" == "1" ]]; then
echo "coverage environment retained: $WORK" >&2
else
rm -rf -- "$WORK"
rmdir "$DEFAULT_TMP" 2>/dev/null || true
rmdir "${HOME:-/var/tmp}/.cache" 2>/dev/null || true
echo "cleaned isolated coverage environment" >&2
fi
exit "$status"
}
trap cleanup EXIT HUP INT TERM
DOC="$ROOT/crates/apps/nigig-build/src/construction_frame/pages/workspace/doc"
MANIFEST="$ROOT/crates/apps/nigig-build/Cargo.toml"
# Root-level pure files. Subdirectories are copied whole (their mod.rs
# is pure too), listed in PURE_DIRS below. Adding a new pure module at
# the root means adding it here, otherwise it is silently unmeasured --
# the watchdog at the end of section 3 looks for exactly that drift.
ROOT_FILES=(
advanced_json.rs
crdt_bridge.rs
mobile_gesture.rs
persistence.rs
projection_layout.rs
projection_session.rs
)
PURE_DIRS=(
collaboration
editing
layout
model
plugins
)
# ---------------------------------------------------------------------------
# 1. Isolated toolchain with the coverage instrumentation components
# ---------------------------------------------------------------------------
export RUSTUP_HOME="$WORK/rustup"
export CARGO_HOME="$WORK/cargo"
export CARGO_TARGET_DIR="$WORK/target"
export PATH="$CARGO_HOME/bin:$PATH"
export LLVM_PROFILE_FILE="$WORK/profiles/%p-%m.profraw"
export RUSTFLAGS="-C instrument-coverage -C codegen-units=1 -C opt-level=0"
mkdir -p "$WORK/profiles"
curl --fail --location https://sh.rustup.rs -o "$WORK/rustup-init"
chmod 700 "$WORK/rustup-init"
"$WORK/rustup-init" -y --profile minimal --default-toolchain "$TOOLCHAIN" \
--component llvm-tools-preview --no-modify-path
# ---------------------------------------------------------------------------
# 2. makepad-math sources at the pinned rev (the only Makepad the pure
# files touch). Same sparse/blobless trick as tools/test-cad-coverage.sh:
# 29 MB and seconds instead of a 319 MB shallow clone.
# ---------------------------------------------------------------------------
MAKEPAD_REV="$(sed -n 's/.*makepad-widgets.*rev = "\([0-9a-f]\{40\}\)".*/\1/p' \
"$MANIFEST" | head -1)"
[[ -n "$MAKEPAD_REV" ]] || { echo "cannot read the pinned makepad rev"; exit 1; }
MAKEPAD="$WORK/makepad"
git init --quiet "$MAKEPAD"
git -C "$MAKEPAD" remote add origin https://gitdab.com/andodeki/makepad
# The closure of makepad-math's path dependencies: math -> micro_serde ->
# live_id, each with a proc-macro sibling.
git -C "$MAKEPAD" sparse-checkout set --cone \
libs/math libs/micro_serde libs/live_id libs/micro_proc_macro
git -C "$MAKEPAD" fetch --quiet --depth 1 --filter=blob:none origin "$MAKEPAD_REV"
git -C "$MAKEPAD" checkout --quiet FETCH_HEAD
for manifest in libs/math libs/micro_serde libs/micro_serde/derive \
libs/micro_proc_macro libs/live_id libs/live_id/id_macros; do
test -f "$MAKEPAD/$manifest/Cargo.toml" \
|| { echo "missing $manifest in the Makepad checkout"; exit 1; }
done
# ---------------------------------------------------------------------------
# 3. Assemble the host-only harness
# ---------------------------------------------------------------------------
HARNESS="$WORK/harness"
DEST="$HARNESS/src/construction_frame/pages/workspace/doc"
mkdir -p "$DEST" "$WORK/shim/src"
cat > "$WORK/shim/Cargo.toml" <<EOF
[package]
name = "makepad-widgets"
version = "0.1.0"
edition = "2021"
[lib]
name = "makepad_widgets"
path = "src/lib.rs"
[dependencies]
makepad-math = { path = "$MAKEPAD/libs/math" }
EOF
cat > "$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" <<EOF
[package]
name = "doc-workspace-cov"
version = "0.0.0"
edition = "2021"
publish = false
# The lib is named nigig_build so the copied sources resolve their
# `crate::construction_frame::...` paths against this crate unchanged.
[lib]
name = "nigig_build"
path = "src/lib.rs"
[dependencies]
makepad-widgets = { path = "$WORK/shim" }
doc-engine = { path = "$WORK/doc-engine" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
EOF
cat > "$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