diff --git a/crates/apps/nigig-pay-ui/src/shared_pay_sheet.rs b/crates/apps/nigig-pay-ui/src/shared_pay_sheet.rs index afaeee1..2c4cf3c 100644 --- a/crates/apps/nigig-pay-ui/src/shared_pay_sheet.rs +++ b/crates/apps/nigig-pay-ui/src/shared_pay_sheet.rs @@ -2384,6 +2384,7 @@ script_mod! { // `demo` builds unhide it on init. pin_input := mod.widgets.RobrixTextInput { width: Fill + visible: false empty_text: "M-Pesa PIN" is_password: true draw_text +: { diff --git a/crates/apps/spreadsheet/spreadsheet-engine/src/workbook_api.rs b/crates/apps/spreadsheet/spreadsheet-engine/src/workbook_api.rs index 03931a9..0cb4a68 100644 --- a/crates/apps/spreadsheet/spreadsheet-engine/src/workbook_api.rs +++ b/crates/apps/spreadsheet/spreadsheet-engine/src/workbook_api.rs @@ -784,6 +784,177 @@ mod tests { assert_eq!(wb.get_raw(0, 2), ""); } + /// Read a cell's style through the public data map. `SpreadsheetData` + /// exposes `cells` directly rather than a getter, and a missing cell is + /// indistinguishable from a default-styled one for these assertions. + fn style_of(wb: &Workbook, row: u32, col: u32) -> crate::style::CellStyle { + wb.active_sheet_data() + .cells + .get(&CellId::new(row, col)) + .map(|cell| cell.style.clone()) + .unwrap_or_default() + } + + // ── Coverage-directed tests ─────────────────────────────────────────── + // + // Added from an uncovered-line report, not by guesswork: workbook_api.rs + // sat at 77.80% with 13 public methods never entered by any test. These + // are the styling and cell-mutation entry points the toolbar calls, so + // "never executed in a test" meant a typo in one would ship. + + #[test] + fn with_sheets_builds_a_workbook_and_defaults_to_the_first() { + let mut alpha_data = SpreadsheetData::default(); + alpha_data.set_cell(0, 0, "from-alpha"); + let a = Sheet::new("Alpha", alpha_data); + let b = Sheet::new("Beta", SpreadsheetData::default()); + + let wb = Workbook::with_sheets(vec![a, b]); + assert_eq!(wb.sheet_count(), 2); + assert_eq!(wb.active_sheet(), 0); + assert_eq!(wb.get_raw(0, 0), "from-alpha"); + } + + #[test] + fn sheet_data_mut_edits_the_addressed_sheet_only() { + let mut wb = Workbook::new(); + wb.add_sheet("Second"); + + wb.sheet_data_mut(1) + .expect("sheet 1 exists") + .set_cell(0, 0, "only-here"); + + assert_eq!(wb.sheet_data(0).expect("sheet 0").get_raw(0, 0), ""); + assert_eq!( + wb.sheet_data(1).expect("sheet 1").get_raw(0, 0), + "only-here" + ); + // An out-of-range index must not panic or silently target sheet 0. + assert!(wb.sheet_data_mut(99).is_none()); + } + + #[test] + fn get_edit_value_returns_the_formula_not_the_result() { + let mut wb = Workbook::new(); + wb.set_cell(0, 0, "2"); + wb.set_cell(0, 1, "=A1+3"); + wb.evaluate(); + + // The formula bar must show what the user typed. + assert_eq!(wb.get_edit_value(0, 1), "=A1+3"); + // While the grid shows the computed value. + assert_eq!(wb.get_display_value(0, 1), "5"); + } + + #[test] + fn remove_cell_clears_the_value() { + let mut wb = Workbook::new(); + wb.set_cell(0, 0, "doomed"); + assert_eq!(wb.get_raw(0, 0), "doomed"); + + wb.remove_cell(0, 0); + assert_eq!(wb.get_raw(0, 0), ""); + } + + /// Pins a sharp edge in this API, found while writing coverage tests. + /// + /// `set_cell` calls `begin_recording()` before applying. The other eight + /// public mutators — `remove_cell`, `put_cell`, `set_col_width`, + /// `set_row_height`, `toggle_bold`, `set_number_format`, `set_alignment`, + /// `set_bg_color` — do not. Calling one of those directly and then + /// `undo()` reverts *the previous recorded edit*, not the mutation just + /// made. + /// + /// This is **not** a shipped defect: every `spreadsheet-ui` call site + /// takes its own `data.snapshot()` first, which is why the behaviour has + /// never been visible. It is a trap for the next caller, so it is + /// asserted here rather than left to be rediscovered — and if the + /// mutators are ever made self-recording, this test fails and says so. + #[test] + fn only_set_cell_records_its_own_undo_step() { + let mut wb = Workbook::new(); + wb.set_cell(0, 0, "first"); + wb.set_cell(0, 0, "second"); + + // A direct mutator does not open a new undo step... + wb.remove_cell(0, 0); + assert_eq!(wb.get_raw(0, 0), ""); + + // ...so undo rewinds the last *recorded* edit, restoring "first" + // rather than the removed "second". + assert!(wb.undo()); + assert_eq!( + wb.get_raw(0, 0), + "first", + "if this now reads \"second\", the mutators became self-recording \ + and the doc comment above is stale" + ); + } + + #[test] + fn column_width_and_row_height_overrides_are_stored() { + let mut wb = Workbook::new(); + wb.set_col_width(2, 180.0); + wb.set_row_height(5, 42.0); + + let data = wb.active_sheet_data(); + assert_eq!(data.col_widths.get(&2).copied(), Some(180.0)); + assert_eq!(data.row_heights.get(&5).copied(), Some(42.0)); + } + + #[test] + fn toggle_bold_flips_and_restores() { + let mut wb = Workbook::new(); + wb.set_cell(0, 0, "x"); + + wb.toggle_bold(0, 0); + assert!(style_of(&wb, 0, 0).bold); + + wb.toggle_bold(0, 0); + assert!(!style_of(&wb, 0, 0).bold, "toggle must be symmetric"); + } + + /// Styling an empty cell must create it rather than being dropped: the + /// toolbar lets a user format a blank cell before typing into it. + #[test] + fn styling_an_empty_cell_creates_it() { + let mut wb = Workbook::new(); + wb.toggle_bold(3, 3); + assert!(style_of(&wb, 3, 3).bold); + } + + #[test] + fn number_format_alignment_and_background_are_applied() { + let mut wb = Workbook::new(); + wb.set_cell(0, 0, "1234.5"); + + wb.set_number_format(0, 0, NumberFormat::Currency); + wb.set_alignment(0, 0, CellAlign::Right); + let teal = Color::new(0.0, 0.5, 0.5, 1.0); + wb.set_bg_color(0, 0, Some(teal)); + + let style = style_of(&wb, 0, 0); + assert_eq!(style.number_format, NumberFormat::Currency); + assert_eq!(style.align, CellAlign::Right); + assert_eq!(style.bg_color, Some(teal)); + + // Clearing the background must be expressible. + wb.set_bg_color(0, 0, None); + assert_eq!(style_of(&wb, 0, 0).bg_color, None); + } + + #[test] + fn evaluate_recalculates_only_the_active_sheet() { + let mut wb = Workbook::new(); + wb.add_sheet("Second"); + + wb.set_active_sheet(1); + wb.set_cell(0, 0, "7"); + wb.set_cell(0, 1, "=A1*2"); + wb.evaluate(); + assert_eq!(wb.get_display_value(0, 1), "14"); + } + #[test] fn workbook_evaluate_all() { let mut wb = Workbook::new(); diff --git a/crates/apps/spreadsheet/spreadsheet-ui/src/event_router.rs b/crates/apps/spreadsheet/spreadsheet-ui/src/event_router.rs index 075f1cc..ac18e8d 100644 --- a/crates/apps/spreadsheet/spreadsheet-ui/src/event_router.rs +++ b/crates/apps/spreadsheet/spreadsheet-ui/src/event_router.rs @@ -89,4 +89,90 @@ mod tests { } ); } + + /// Coverage-directed: only `Up` was exercised, so a transposed or + /// sign-flipped delta in any other arrow would have shipped silently. + /// Each direction is asserted against its own axis and sign. + #[test] + fn every_arrow_maps_to_its_own_axis_and_sign() { + let cases = [ + (NavigationKey::Up, -1i32, 0i32), + (NavigationKey::Down, 1, 0), + (NavigationKey::Left, 0, -1), + (NavigationKey::Right, 0, 1), + ]; + for (key, want_row, want_col) in cases { + match route_navigation(key, false) { + InputIntent::MoveSelection { + row_delta, + col_delta, + extend, + } => { + assert_eq!(row_delta, want_row, "{key:?} row delta"); + assert_eq!(col_delta, want_col, "{key:?} col delta"); + assert!(!extend, "{key:?} must not extend when not asked"); + } + other => panic!("{key:?} routed to {other:?}"), + } + } + } + + /// `extend` is what turns an arrow into a shift-arrow range grab. It must + /// pass through on every direction, not just the one that was tested. + #[test] + fn extend_passes_through_on_every_direction() { + for key in [ + NavigationKey::Up, + NavigationKey::Down, + NavigationKey::Left, + NavigationKey::Right, + ] { + match route_navigation(key, true) { + InputIntent::MoveSelection { extend, .. } => { + assert!(extend, "{key:?} dropped the extend flag") + } + other => panic!("{key:?} routed to {other:?}"), + } + } + } + + /// Undo and Redo are not movements and must not be routed as one — a + /// `MoveSelection { 0, 0 }` would look harmless and do nothing. + #[test] + fn undo_and_redo_are_not_routed_as_movement() { + assert_eq!( + route_navigation(NavigationKey::Undo, false), + InputIntent::Undo + ); + assert_eq!( + route_navigation(NavigationKey::Redo, false), + InputIntent::Redo + ); + // The extend flag is meaningless for these and must not change them. + assert_eq!( + route_navigation(NavigationKey::Undo, true), + InputIntent::Undo + ); + assert_eq!( + route_navigation(NavigationKey::Redo, true), + InputIntent::Redo + ); + } + + #[test] + fn edit_and_range_routes_carry_their_arguments() { + assert_eq!( + route_select_range((1, 2), (3, 4)), + InputIntent::SelectRange { + anchor: (1, 2), + head: (3, 4) + } + ); + assert_eq!( + route_edit_begin(7, 9), + InputIntent::BeginEdit { row: 7, col: 9 } + ); + assert_eq!(route_edit_commit(), InputIntent::CommitEdit); + assert_eq!(route_edit_cancel(), InputIntent::CancelEdit); + } } diff --git a/crates/apps/spreadsheet/spreadsheet-ui/src/selection.rs b/crates/apps/spreadsheet/spreadsheet-ui/src/selection.rs index a721771..0d7f8ff 100644 --- a/crates/apps/spreadsheet/spreadsheet-ui/src/selection.rs +++ b/crates/apps/spreadsheet/spreadsheet-ui/src/selection.rs @@ -48,4 +48,64 @@ mod tests { selection.clear(); assert_eq!(selection.bounds(), None); } + + /// `anchor()` and `head()` were never called by a test, so the two could + /// have been swapped without anything noticing. `bounds()` normalises + /// them, which hides the difference — these assert the raw values. + #[test] + fn anchor_stays_put_while_head_follows_an_extend() { + let mut selection = SelectionController::new(); + assert_eq!(selection.anchor(), None); + assert_eq!(selection.head(), None); + + selection.set(2, 3, false); + assert_eq!(selection.anchor(), Some((2, 3))); + assert_eq!(selection.head(), Some((2, 3))); + + // Extending moves only the head. + selection.set(5, 1, true); + assert_eq!(selection.anchor(), Some((2, 3)), "anchor moved on extend"); + assert_eq!(selection.head(), Some((5, 1))); + + // A non-extending click re-anchors both. + selection.set(9, 9, false); + assert_eq!(selection.anchor(), Some((9, 9))); + assert_eq!(selection.head(), Some((9, 9))); + } + + /// Shift-clicking with nothing selected must anchor rather than leave a + /// head with no anchor, which `bounds()` would report as no selection. + #[test] + fn extending_from_an_empty_selection_anchors_instead() { + let mut selection = SelectionController::new(); + selection.set(4, 4, true); + assert_eq!(selection.anchor(), Some((4, 4))); + assert_eq!(selection.bounds(), Some((4, 4, 4, 4))); + } + + #[test] + fn clear_resets_both_ends() { + let mut selection = SelectionController::new(); + selection.set(1, 1, false); + selection.set(3, 3, true); + selection.clear(); + assert_eq!(selection.anchor(), None); + assert_eq!(selection.head(), None); + assert_eq!(selection.bounds(), None); + } + + /// Bounds must normalise whichever way the drag went. + #[test] + fn bounds_are_normalised_in_both_drag_directions() { + let mut forward = SelectionController::new(); + forward.set(1, 1, false); + forward.set(4, 6, true); + + let mut backward = SelectionController::new(); + backward.set(4, 6, false); + backward.set(1, 1, true); + + assert_eq!(forward.bounds(), backward.bounds()); + assert_eq!(forward.bounds(), Some((1, 1, 4, 6))); + } } diff --git a/tools/check-no-pin-capture.sh b/tools/check-no-pin-capture.sh index 335502a..1222ac4 100644 --- a/tools/check-no-pin-capture.sh +++ b/tools/check-no-pin-capture.sh @@ -30,6 +30,31 @@ if [[ ! -f "$SHEET" ]]; then exit 2 fi +# ── Part 1: the PIN controls must be hidden in the UI definition itself ────── +# +# The compile probe below proves the *field* is absent from a packaging build. +# It says nothing about the default build, where the field legitimately exists +# and the DSL is what keeps the control off screen until a demo build unhides +# it on init. +# +# That distinction matters: an unrelated commit removed `visible: false` from +# `pin_input` while leaving it on `pin_eye_btn`, and every existing gate still +# passed. A hidden-by-default control that becomes visible-by-default is +# exactly the DSL-reload hole review item 0.3 asks to close (defect B11). +for control in pin_input pin_eye_btn; do + if ! awk -v ctrl="$control" ' + index($0, ctrl " :=") { found = 1; next } + found && /visible: false/ { ok = 1; exit } + found && /^[[:space:]]*}/ { exit } + END { exit(ok ? 0 : 1) } + ' "$SHEET"; then + echo "FAIL: $control is not hidden by default in the DSL" >&2 + echo " (review item 0.3: hiding must survive a DSL reload)" >&2 + exit 1 + fi +done +echo "PIN controls are hidden by default in the UI definition" + BACKUP="$(mktemp)" cp "$SHEET" "$BACKUP" restore() { diff --git a/tools/test-spreadsheet-coverage.sh b/tools/test-spreadsheet-coverage.sh index 4416641..d5068c6 100755 --- a/tools/test-spreadsheet-coverage.sh +++ b/tools/test-spreadsheet-coverage.sh @@ -1,40 +1,232 @@ #!/usr/bin/env bash -# Temporary LLVM source-coverage run for Spreadsheet's pure engine. +# LLVM source-coverage for the Spreadsheet engine and UI controller logic. +# +# Everything lives in one temporary directory — toolchain, cargo caches, crate +# copies, build artefacts, profiles and the report — and it is removed on any +# exit path. Nothing is installed into the host, and the repository's own +# `target/` is untouched. +# +# Usage: +# ./tools/test-spreadsheet-coverage.sh # engine + ui +# COVERAGE_TARGET=engine ./tools/test-spreadsheet-coverage.sh +# COVERAGE_TARGET=ui ./tools/test-spreadsheet-coverage.sh +# KEEP_COVERAGE=1 ./tools/test-spreadsheet-coverage.sh # keeps the report +# +# `KEEP_COVERAGE=1` additionally writes an uncovered-line listing, which is +# what makes "add tests for uncovered branches" a directed activity rather +# than guesswork. + set -Eeuo pipefail IFS=$'\n\t' + ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -WORK="$(mktemp -d "${TMPDIR:-/tmp}/spreadsheet-coverage.XXXXXXXX")" +COVERAGE_TARGET="${COVERAGE_TARGET:-all}" KEEP_COVERAGE="${KEEP_COVERAGE:-0}" +ENGINE_FLOOR="${ENGINE_FLOOR:-90}" +UI_FLOOR="${UI_FLOOR:-94}" + +case "$COVERAGE_TARGET" in + all | engine | ui) ;; + *) + echo "error: COVERAGE_TARGET must be all, engine or ui" >&2 + exit 2 + ;; +esac + +# Two constraints pull against each other here. +# +# `/tmp` is a small tmpfs on some hosts and an instrumented build plus a +# toolchain will not fit, so the default must be somewhere roomier. But the +# work directory must *not* sit inside the repository: Cargo would then treat +# the copied crates as workspace members and refuse to build them. +# +# `$HOME/.cache` satisfies both — same roomy filesystem, outside the +# workspace. A caller-supplied TMPDIR wins, and is assumed to be sane. +DEFAULT_TMP="${HOME:-/var/tmp}/.cache/nigig-coverage" +mkdir -p "${TMPDIR:-$DEFAULT_TMP}" +WORK="$(mktemp -d "${TMPDIR:-$DEFAULT_TMP}/spreadsheet-coverage.XXXXXXXX")" + cleanup() { - local s=$? - if [[ "$KEEP_COVERAGE" == "1" ]]; then - echo "coverage environment retained: $WORK" >&2 - else - rm -rf "$WORK" - echo "cleaned isolated coverage environment" >&2 - fi - exit "$s" + local status=$? + if [[ "$KEEP_COVERAGE" == "1" ]]; then + echo "coverage environment retained: $WORK" >&2 + else + rm -rf -- "$WORK" + # Only remove the default parent, and only when we created it and it + # is empty: a caller-supplied TMPDIR is not ours to delete. + 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 -export RUSTUP_HOME="$WORK/rustup" CARGO_HOME="$WORK/cargo" CARGO_TARGET_DIR="$WORK/target" -export PATH="$CARGO_HOME/bin:$PATH" LLVM_PROFILE_FILE="$WORK/profiles/%p-%m.profraw" + +TOOLCHAIN="$(sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \ + "$ROOT/rust-toolchain.toml" | head -n 1)" +if [[ -z "$TOOLCHAIN" ]]; then + echo "error: could not read channel from rust-toolchain.toml" >&2 + exit 2 +fi + +export RUSTUP_HOME="$WORK/rustup" +export CARGO_HOME="$WORK/cargo" +export CARGO_TARGET_DIR="$WORK/target" +export PATH="$CARGO_HOME/bin:$PATH" +export CARGO_NET_GIT_FETCH_WITH_CLI=true +export CARGO_INCREMENTAL=0 +export LLVM_PROFILE_FILE="$WORK/profiles/%p-%m.profraw" +# `-C opt-level=0` keeps line mapping honest: optimisation merges and drops +# lines, which makes a coverage report describe a program nobody wrote. 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" + +echo "creating isolated coverage toolchain ($TOOLCHAIN) in $WORK" >&2 +curl --fail --location --proto '=https' --tlsv1.2 https://sh.rustup.rs \ + -o "$WORK/rustup-init" chmod 700 "$WORK/rustup-init" -"$WORK/rustup-init" -y --profile minimal --default-toolchain 1.97.1 --component llvm-tools-preview --no-modify-path -CRATE="$WORK/spreadsheet-engine" -cp -a "$ROOT/crates/apps/spreadsheet/spreadsheet-engine" "$CRATE" -cargo test --manifest-path "$CRATE/Cargo.toml" --all-targets -LLVM_BIN="$RUSTUP_HOME/toolchains/1.97.1-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/bin" -"$LLVM_BIN/llvm-profdata" merge -sparse "$WORK/profiles"/*.profraw -o "$WORK/coverage.profdata" -TEST_BIN=$(find "$CARGO_TARGET_DIR/debug/deps" -maxdepth 1 -type f -executable -name 'spreadsheet_engine-*' | head -1) -"$LLVM_BIN/llvm-cov" report "$TEST_BIN" -instr-profile="$WORK/coverage.profdata" \ - -ignore-filename-regex='(/cargo/registry|/cargo/git|/rustc/|/tmp/spreadsheet-coverage)' \ - "$CRATE/src/lib.rs" "$CRATE/src/data.rs" "$CRATE/src/formula2.rs" "$CRATE/src/workbook.rs" -if [[ "$KEEP_COVERAGE" == "1" ]]; then - "$LLVM_BIN/llvm-cov" show "$TEST_BIN" -instr-profile="$WORK/coverage.profdata" \ - -ignore-filename-regex='(/cargo/registry|/cargo/git|/rustc/)' \ - | grep -E '^ *[0-9]+\| *0\|' > "$WORK/uncovered-lines.txt" || true - echo "uncovered line report: $WORK/uncovered-lines.txt" >&2 +"$WORK/rustup-init" -y --profile minimal --default-toolchain "$TOOLCHAIN" \ + --component llvm-tools-preview --no-modify-path + +LLVM_BIN="$RUSTUP_HOME/toolchains/$TOOLCHAIN-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/bin" + +# ── Exclusions (step 4) ────────────────────────────────────────────────────── +# +# Two categories are excluded, for different reasons. +# +# 1. **Not our code.** Registry and git dependencies, the Rust standard +# library, and the copy path itself. Including them measures other +# people's tests. +# +# 2. **Generated Makepad DSL and platform startup.** `script_mod! { ... }` +# expands to widget-registration code that no unit test can reach without +# a live `Cx` and a window, and `src/bin/` is the desktop entry point. +# Counting them does not report "untested logic", it reports "logic that +# cannot be reached from a test", which drags the number down while +# telling you nothing actionable. They are excluded by *file*, and the +# files that hold them are named here rather than pattern-matched, so +# adding a new one is a deliberate act. +# +# Everything else in the UI crate — the controller modules — is measured. +# Only foreign code is filtered. The copied crates live *under* the work +# directory, so matching the work path here would exclude the sources being +# measured — which is exactly what an earlier version of this script did, +# reporting a confident 0%. +IGNORE_COMMON='(/cargo/registry|/cargo/git|/rustc/)' +# grid.rs, ui.rs and workspace.rs are the three files carrying `script_mod!`. +IGNORE_UI="$IGNORE_COMMON"'|spreadsheet-ui/src/(grid|ui|workspace)\.rs|spreadsheet-ui/src/bin/' + +report_for() { + local label="$1" test_bin="$2" profdata="$3" ignore="$4" floor="$5" + shift 5 + local sources=("$@") + + echo >&2 + echo "── $label ─────────────────────────────────────────────" >&2 + "$LLVM_BIN/llvm-cov" report "$test_bin" \ + -instr-profile="$profdata" \ + -ignore-filename-regex="$ignore" \ + "${sources[@]}" + + local covered + covered="$("$LLVM_BIN/llvm-cov" export "$test_bin" \ + -instr-profile="$profdata" \ + -ignore-filename-regex="$ignore" \ + -summary-only "${sources[@]}" \ + | grep -o '"lines":{[^}]*}' | tail -1 \ + | grep -o '"percent":[0-9.]*' | cut -d: -f2)" + covered="${covered%%.*}" + if [[ -z "$covered" ]]; then + echo "error: could not read $label line coverage" >&2 + exit 1 + fi + if (( covered < floor )); then + echo "FAIL: $label line coverage ${covered}% is below the ${floor}% floor" >&2 + exit 1 + fi + echo "$label line coverage ${covered}% meets the ${floor}% floor" >&2 +} + +uncovered_listing() { + local label="$1" test_bin="$2" profdata="$3" ignore="$4" + shift 4 + "$LLVM_BIN/llvm-cov" show "$test_bin" \ + -instr-profile="$profdata" \ + -ignore-filename-regex="$ignore" \ + "$@" \ + | awk ' + # `llvm-cov show` prints a bare path line before each file. Match + # any line that is not a numbered source line and ends in `.rs` + # or `.rs:` — an earlier version required a trailing colon and + # silently produced an empty listing. + /^[^ 0-9]/ && /\.rs:?$/ { file = $0; sub(/:$/, "", file); next } + /^ *[0-9]+\| *0\|/ { print file ": " $0 } + ' > "$WORK/uncovered-$label.txt" || true + echo "uncovered lines: $WORK/uncovered-$label.txt" >&2 +} + +# ── Engine ─────────────────────────────────────────────────────────────────── +if [[ "$COVERAGE_TARGET" == "all" || "$COVERAGE_TARGET" == "engine" ]]; then + ENGINE="$WORK/spreadsheet-engine" + cp -a "$ROOT/crates/apps/spreadsheet/spreadsheet-engine" "$ENGINE" + + # `--all-targets` picks up tests/sync_flow.rs as well as the unit tests. + cargo test --manifest-path "$ENGINE/Cargo.toml" --all-targets + + "$LLVM_BIN/llvm-profdata" merge -sparse "$WORK/profiles"/*.profraw \ + -o "$WORK/engine.profdata" + ENGINE_BIN="$(find "$CARGO_TARGET_DIR/debug/deps" -maxdepth 1 -type f \ + -executable -name 'spreadsheet_engine-*' | head -1)" + if [[ -z "$ENGINE_BIN" ]]; then + echo "error: could not find the engine test binary" >&2 + exit 1 + fi + + report_for "engine" "$ENGINE_BIN" "$WORK/engine.profdata" \ + "$IGNORE_COMMON" "$ENGINE_FLOOR" "$ENGINE/src" + if [[ "$KEEP_COVERAGE" == "1" ]]; then + uncovered_listing "engine" "$ENGINE_BIN" "$WORK/engine.profdata" \ + "$IGNORE_COMMON" "$ENGINE/src" + fi fi + +# ── UI controller logic ────────────────────────────────────────────────────── +if [[ "$COVERAGE_TARGET" == "all" || "$COVERAGE_TARGET" == "ui" ]]; then + # Makepad's Linux backend needs native libraries to link a test binary. + "$ROOT/tools/makepad-native-libs.sh" --check + + rm -f "$WORK/profiles"/*.profraw + mkdir -p "$WORK/spreadsheet" + cp -a "$ROOT/crates/apps/spreadsheet/spreadsheet-engine" \ + "$WORK/spreadsheet/spreadsheet-engine" + cp -a "$ROOT/crates/apps/spreadsheet/spreadsheet-ui" \ + "$WORK/spreadsheet/spreadsheet-ui" + UI="$WORK/spreadsheet/spreadsheet-ui" + + cargo test --manifest-path "$UI/Cargo.toml" --lib + + "$LLVM_BIN/llvm-profdata" merge -sparse "$WORK/profiles"/*.profraw \ + -o "$WORK/ui.profdata" + UI_BIN="$(find "$CARGO_TARGET_DIR/debug/deps" -maxdepth 1 -type f \ + -executable -name 'spreadsheet_ui-*' | head -1)" + if [[ -z "$UI_BIN" ]]; then + echo "error: could not find the UI test binary" >&2 + exit 1 + fi + + # Only the controller modules are measured; see the exclusion note above. + report_for "ui-controllers" "$UI_BIN" "$WORK/ui.profdata" \ + "$IGNORE_UI" "$UI_FLOOR" \ + "$UI/src/clipboard.rs" "$UI/src/edit.rs" "$UI/src/event_router.rs" \ + "$UI/src/input.rs" "$UI/src/model.rs" "$UI/src/render_cache.rs" \ + "$UI/src/selection.rs" "$UI/src/text_measure.rs" + if [[ "$KEEP_COVERAGE" == "1" ]]; then + uncovered_listing "ui" "$UI_BIN" "$WORK/ui.profdata" "$IGNORE_UI" \ + "$UI/src/clipboard.rs" "$UI/src/edit.rs" "$UI/src/event_router.rs" \ + "$UI/src/input.rs" "$UI/src/model.rs" "$UI/src/render_cache.rs" \ + "$UI/src/selection.rs" "$UI/src/text_measure.rs" + fi +fi + +echo >&2 +echo "spreadsheet coverage run complete" >&2