nigig-org/tools/test-spreadsheet-coverage.sh
andodeki 06a3de6126
Some checks failed
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
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
feat(spreadsheet): dropdown cells and button cells (#9 remainder, complete)
Finish the widget-in-cell catalog with the two deferred controls, each a
plain value plus a render flag (same convention as checkbox/slider/Markdown).

- Dropdown: CellStyle.choices (a serialized, pipe-escaped list) makes a
  cell cycle through its choices on click. The toolbar "Drop" button parses
  the selected cell's value as a "Low|Med|High" list into choices (undoable
  SetChoices) and selects the first entry; a dropdown renders its value
  with a trailing ▾ affordance. dropdown.rs holds next_choice /
  parse_choice_list.
- Button: CellStyle.button makes a cell a button whose value is a
  TARGET[+N] action spec (A1 ref + optional signed step, default +1).
  Clicking increments the target cell's numeric value through set_cell
  (undoable, recalculates dependents) — the spreadsheet-native counterpart
  of the reference's "+10" boost. button.rs holds parse_button_spec /
  button_step / format_step_value; the cell renders as a raised box with
  the spec centred. Toolbar "Btn" toggles the flag.

Both flags ride in the render cache so cached cells stay styled, and both
commands route through WorkbookCommand (apply + apply_command + dirty
marking). Choices serialize after the markdown/slider/button flags; older
files default to off/empty.

Engine: 491 lib tests (+4) + integration. UI controllers: 142 tests (+8,
button 5 + dropdown 4). Coverage: engine 96.59%, ui-controllers 99.45%
(floors 96); button.rs and dropdown.rs at 100%.
2026-08-21 04:52:00 +00:00

284 lines
13 KiB
Bash
Executable file

#!/usr/bin/env bash
# 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)"
COVERAGE_TARGET="${COVERAGE_TARGET:-all}"
KEEP_COVERAGE="${KEEP_COVERAGE:-0}"
ENGINE_FLOOR="${ENGINE_FLOOR:-96}"
UI_FLOOR="${UI_FLOOR:-96}"
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 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
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"
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 "$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.
#
# **This exclusion has to be audited, not trusted.** `grid.rs` is 2,700
# lines and only its last ~30 are `script_mod!`; of its 59 functions, 36
# took no `cx`, no `Event` and no `Scope`. Those were pure arithmetic —
# hit testing, cell rectangles, frozen panes, autofill handles — hidden
# behind a file-level exclusion and carrying no tests at all. They now
# live in `geometry.rs`, which is measured, and `grid.rs` delegates to
# it. An exclusion that quietly grows to cover real logic is worse than
# no exclusion, because the number stays green while the coverage goes
# away. Before widening this list, check what is actually inside.
#
# 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!`.
# Their *testable* logic belongs in a measured module — see geometry.rs —
# rather than behind this exclusion.
IGNORE_UI="$IGNORE_COMMON"'|spreadsheet-ui/src/(grid|ui|workspace|trend_chart|virtual_grid)\.rs|spreadsheet-ui/src/bin/'
# Collect every instrumented test binary as `-object` arguments.
#
# One binary is not enough, and the shortfall is silent. Cargo builds each
# integration test into its *own* executable, so measuring only the lib-test
# binary discards everything `tests/` exercised. That is not a rounding
# error: `persistence.rs` reported 41.77% with 14 of its 17 functions
# apparently never called, while `tests/sync_flow.rs` was calling
# `save_spreadsheet_state` and `load_saved_spreadsheet_state` on every run
# and passing. The functions were covered; the report was reading the wrong
# object. Same class of defect as the two already fixed in this script.
collect_objects() {
local pattern="$1"
OBJECTS=()
local bin first=1
while IFS= read -r bin; do
# llvm-cov takes the first binary positionally and the rest through
# `-object`. Passing every one as `-object` makes it read the first
# *source* path as the positional binary and fail with "not a valid
# object file", so the split matters.
if (( first )); then
OBJECTS+=("$bin")
first=0
else
OBJECTS+=(-object "$bin")
fi
done < <(find "$CARGO_TARGET_DIR/debug/deps" -maxdepth 1 -type f \
-executable ! -name '*.d' ! -name '*.so' -name "$pattern" | sort)
}
report_for() {
local label="$1" profdata="$2" ignore="$3" floor="$4"
shift 4
local sources=("$@")
echo >&2
echo "── $label ─────────────────────────────────────────────" >&2
"$LLVM_BIN/llvm-cov" report "${OBJECTS[@]}" \
-instr-profile="$profdata" \
-ignore-filename-regex="$ignore" \
"${sources[@]}"
local covered
covered="$("$LLVM_BIN/llvm-cov" export "${OBJECTS[@]}" \
-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" profdata="$2" ignore="$3"
shift 3
"$LLVM_BIN/llvm-cov" show "${OBJECTS[@]}" \
-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"
# Every engine test binary: the lib tests plus each file in tests/.
collect_objects '*'
if [[ "${#OBJECTS[@]}" -eq 0 ]]; then
echo "error: no instrumented engine test binaries were produced" >&2
exit 1
fi
# A directory is accepted as a source filter only with a single
# `-object`; with several, llvm-cov rejects it. Expand to the files.
ENGINE_SOURCES=()
while IFS= read -r f; do
ENGINE_SOURCES+=("$f")
done < <(find "$ENGINE/src" -name '*.rs' | sort)
report_for "engine" "$WORK/engine.profdata" \
"$IGNORE_COMMON" "$ENGINE_FLOOR" "${ENGINE_SOURCES[@]}"
if [[ "$KEEP_COVERAGE" == "1" ]]; then
uncovered_listing "engine" "$WORK/engine.profdata" \
"$IGNORE_COMMON" "${ENGINE_SOURCES[@]}"
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.
bash "$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"
# Measure the headless controller/unit suite only. The makepad-test
# integration binary requires a supported Studio runtime and is kept in
# the separate spreadsheet-ui test target.
cargo test --manifest-path "$UI/Cargo.toml" --lib
"$LLVM_BIN/llvm-profdata" merge -sparse "$WORK/profiles"/*.profraw \
-o "$WORK/ui.profdata"
collect_objects '*'
if [[ "${#OBJECTS[@]}" -eq 0 ]]; then
echo "error: no instrumented UI test binaries were produced" >&2
exit 1
fi
# Only the controller modules are measured; see the exclusion note above.
report_for "ui-controllers" "$WORK/ui.profdata" \
"$IGNORE_UI" "$UI_FLOOR" \
"$UI/src/button.rs" "$UI/src/chart.rs" "$UI/src/checkbox.rs" "$UI/src/clipboard.rs" "$UI/src/dropdown.rs" "$UI/src/edit.rs" "$UI/src/event_router.rs" \
"$UI/src/formula_bar.rs" \
"$UI/src/geometry.rs" "$UI/src/input.rs" "$UI/src/market.rs" "$UI/src/markdown.rs" "$UI/src/model.rs" "$UI/src/render_cache.rs" \
"$UI/src/selection.rs" "$UI/src/slider.rs" "$UI/src/sort_state.rs" "$UI/src/sparkline.rs" "$UI/src/text_measure.rs" "$UI/src/zoom.rs"
if [[ "$KEEP_COVERAGE" == "1" ]]; then
uncovered_listing "ui" "$WORK/ui.profdata" "$IGNORE_UI" \
"$UI/src/button.rs" "$UI/src/chart.rs" "$UI/src/checkbox.rs" "$UI/src/clipboard.rs" "$UI/src/dropdown.rs" "$UI/src/edit.rs" "$UI/src/event_router.rs" \
"$UI/src/formula_bar.rs" \
"$UI/src/geometry.rs" "$UI/src/input.rs" "$UI/src/market.rs" "$UI/src/markdown.rs" "$UI/src/model.rs" "$UI/src/render_cache.rs" \
"$UI/src/selection.rs" "$UI/src/slider.rs" "$UI/src/sort_state.rs" "$UI/src/sparkline.rs" "$UI/src/text_measure.rs" "$UI/src/zoom.rs"
fi
fi
echo >&2
echo "spreadsheet coverage run complete" >&2