Compare commits
3 commits
179fe0533d
...
b478945c34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b478945c34 | ||
|
|
949cf24189 | ||
|
|
71c31cd19f |
9 changed files with 4852 additions and 841 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
# Doc-workspace source coverage
|
||||
|
||||
`tools/test-doc-workspace-coverage.sh` measures line/region coverage for the
|
||||
**pure** (widget-free) part of this module and enforces floors in CI
|
||||
(`.forgejo/workflows/nigig-build.yml`, job `doc-workspace-coverage`). The same
|
||||
suite also runs uninstrumented inside `cargo test -p nigig-build --lib`, so
|
||||
every test executes twice: once in the real crate and once in the harness.
|
||||
|
||||
## How it works
|
||||
|
||||
The script copies the dependency-free sources — `advanced_json.rs`,
|
||||
`crdt_bridge.rs`, `mobile_gesture.rs`, `persistence.rs`,
|
||||
`projection_layout.rs`, `projection_session.rs`, and the `collaboration/`,
|
||||
`editing/`, `layout/`, `model/`, `plugins/` trees — plus `tests_pure.rs`
|
||||
byte-for-byte into a temporary host-only crate with the real module path. A 30
|
||||
line `makepad-widgets` shim (re-exporting `makepad_math` plus the `log!` /
|
||||
`error!` macros) satisfies the only Makepad symbols the pure layer uses
|
||||
(`DVec2`, `Rect`, `Vec4f`, `dvec2`, `vec4`).
|
||||
`doc-engine` is included as a path dependency because the pure projection code
|
||||
sits on top of it.
|
||||
|
||||
The harness then installs its own pinned toolchain with
|
||||
`llvm-tools-preview` into the same temp dir, runs the suite under
|
||||
`-C instrument-coverage`, and enforces:
|
||||
|
||||
* a **total** line floor (`DOC_WS_COVERAGE_TOTAL_FLOOR`), and
|
||||
* a **per-file** floor for every instrumented source
|
||||
(`DOC_WS_COVERAGE_PER_FILE_FLOORS`).
|
||||
|
||||
The per-file floors are the point: deleting one file's whole test section
|
||||
moves the total by a point or two and a lone number would wave that through.
|
||||
Lowering a floor is a reviewable edit to the script, not something to do
|
||||
quietly. Everything — toolchain, cargo home, target dir, fetched Makepad
|
||||
tree, profraw data — is removed by a shell trap on every exit path; nothing
|
||||
is written into the repository or `$HOME` unless `KEEP_COVERAGE=1` is set.
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
./tools/test-doc-workspace-coverage.sh # gated run (CI)
|
||||
DOC_WS_COVERAGE_REPORT_ONLY=1 ./tools/test-doc-workspace-coverage.sh # measure only
|
||||
KEEP_COVERAGE=1 ./tools/test-doc-workspace-coverage.sh # keep uncovered-lines.txt + report
|
||||
```
|
||||
|
||||
## Latest measurement (2026-08-17)
|
||||
|
||||
| File | Lines covered |
|
||||
| --- | --- |
|
||||
| TOTAL | **96.76%** (6170 lines) |
|
||||
| advanced_json.rs | 98.02% |
|
||||
| crdt_bridge.rs | 95.45% |
|
||||
| mobile_gesture.rs | 100% |
|
||||
| persistence.rs | 65.85% (see exclusions) |
|
||||
| projection_layout.rs | 98.52% |
|
||||
| projection_session.rs | 100% |
|
||||
| collaboration/* | 100% |
|
||||
| editing/commands.rs | 93.39% |
|
||||
| editing/controller.rs | 93.39% |
|
||||
| editing/history.rs | 95.65% |
|
||||
| layout/* | 94–100% |
|
||||
| model/* | 91–100% |
|
||||
| plugins/mod.rs | 100% |
|
||||
|
||||
## What is intentionally not measured
|
||||
|
||||
* **The widget layer** — `mod.rs`, `crdt_widget.rs`, `widgets/`, `render/`,
|
||||
`projection_renderer.rs`, `tests.rs`. These need `live_design!`, a `Cx`,
|
||||
and an event loop; they are gated by the full `nigig-build` build/test CI
|
||||
jobs instead. The split is deliberate: `tests_pure.rs` contains everything
|
||||
that can run host-only, and both files are compiled into the crate's normal
|
||||
test suite.
|
||||
* **`persistence.rs` write paths** — `save_doc_state` / `save_doc_state_as` /
|
||||
`load_saved_doc_state` resolve the host's real application-data directory
|
||||
and unconditionally write into it. They are thin wrappers over
|
||||
`save_doc_state_to` / `load_saved_doc_state_with`, which the tests drive
|
||||
with explicit temp paths; the wrappers themselves are covered by the
|
||||
widget-runtime tests on device. This is why `persistence.rs` keeps a lower
|
||||
floor (55%) — do not lower it further without a genuine new exclusion.
|
||||
* **Defensive guards** — cycle guards in RGA/block-order traversal
|
||||
(`seen.insert` `continue` arms), unreachable block-id parse failures inside
|
||||
`layout_projection` (engine ids always contain `actor:counter`), and the
|
||||
`unreachable` canvas-text serialization arm's sibling rejections in
|
||||
`advanced_json.rs`. They exist for invariant defense; there is no honest
|
||||
public-API path that reaches them.
|
||||
|
||||
## Defects found by this coverage drive (fixed with tests)
|
||||
|
||||
1. `editing/commands.rs::ReplaceBlockRange` validated the explicit
|
||||
`block_ids` length **after** draining blocks out of the document, so a
|
||||
malformed command destroyed content before reporting failure. Validation
|
||||
now happens before any mutation; a test pins that a rejected replace
|
||||
leaves blocks and ids untouched.
|
||||
2. `model/crdt.rs::visit_children` was dead code (a String-collecting
|
||||
duplicate of `visit_atoms` with no callers). Removed.
|
||||
3. Multi-peer atom-id collisions: two `CrdtMetadata::default()` peers mint
|
||||
identical `AtomId`s, so a concurrent insert silently resurrects instead
|
||||
of inserting. The sync test now assigns distinct
|
||||
`document.crdt.local_actor`s — matching how real peers must be
|
||||
provisioned — and pins the convergent `"hi!"` exchange.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -203,3 +203,5 @@ script_mod! {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_pure;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
404
tools/test-doc-workspace-coverage.sh
Executable file
404
tools/test-doc-workspace-coverage.sh
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue