# CAD test coverage: a phased plan to 100% Written after measuring, not before. Every number here came from a run in this repository; the commands that produced them are named so you can reproduce or refute any of it. ## Where we actually are Two halves, measured by two tools, because one tool cannot see both. | Half | Files | Lines | Covered | Tool | |---|---|---|---|---| | Engine | 15 | 13,752 | **97.16%** | `tools/test-cad-coverage.sh` (gated in CI) | | Widget | 10 | 10,637 | **13.25%** | `tools/test-cad-widget-coverage.sh` (not gated) | 9,618 lines are uncovered. **96% of them are in the widget half**, so that is where the plan spends its effort, and any plan that opens with engine polishing is optimising the wrong term. ### The widget half, per file | File | Lines | Covered | |---|---|---| | `viewport.rs` | 3,548 | 14.37% | | `workspace.rs` | 2,462 | 14.18% | | `viewport_render.rs` | 2,083 | **0.00%** | | `script_bindings.rs` | 724 | 71.27% | | `workspace_actions.rs` | 690 | **0.00%** | | `viewport_input.rs` | 672 | **0.00%** | | `cad_editor_sheet.rs` | 224 | **0.00%** | | `viewport_2d.rs` | 138 | **0.00%** | | `code_editor.rs` | 67 | **0.00%** | | `mod.rs` | 42 | 80.95% | ## Four findings that decide the plan's shape **1. There are already 148 widget tests, and they have never run.** `tests/ui.rs` is 1,889 lines of `#[makepad_test]` tests driving the real app through `TestApp`/`Selector`. Line 1 is `use makepad_test::{...}` — a crate that is not a dependency — so the file has never compiled, and no CI job references it. This is the same defect the spreadsheet-ui suite documents about itself, and the same class as the nigig-email binary that had never been built. **2. They compile with a one-line manifest change, and they run.** Adding to `crates/apps/nigig-build/Cargo.toml`: ```toml [dev-dependencies] makepad-test = { git = "https://gitdab.com/andodeki/makepad", rev = "ecf5a572ab62a1c1598909971f602f99083671cc", package = "makepad-test" } ``` produces a test binary. Under `xvfb-run -a`, all 148 execute in 59 seconds. They do not hang and they do not crash the harness. **3. All 148 currently fail, at a known point.** The harness spawns a child build of the app under test, and that child exits non-zero "before startup". With no `cargo` on the child's PATH the code is 127; after symlinking `cargo` into `/usr/local/bin` it becomes 101 — a build failure — even though `cargo check -p nigig-build --bins` passes cleanly. So the remaining blocker is in how the harness invokes the child build, not in the app. spreadsheet-ui hit the same wall and worked around it by abandoning the `#[makepad_test]` attribute for `run_with_config`, explicitly injecting `PATH`, `CARGO`, `CARGO_HOME` and `RUSTUP_HOME` into the child environment. That workaround is the first thing to try. **4. `#[coverage(off)]` is not available.** It is still unstable on the pinned 1.97.1 toolchain (`error[E0658]`). There is therefore **no way to annotate a line as legitimately unreachable**. Anything genuinely uncoverable must either be covered anyway, moved into a file that the report excludes wholesale, or subtracted honestly from the target. ## On the target itself 100% line coverage is reachable for the engine and is not, by itself, a good goal for the widget half. Two reasons, both concrete: - Coverage counts lines executed, not assertions made. A test that clicks every button and asserts nothing scores identically to one that checks the result. The 148 existing tests are mostly `wait_visible()` — they will move the number a long way while proving only that widgets exist. - Six sessions of engine work found four real defects. Three were found by *reading* uncovered regions and asking why they were unreachable (`mat4_inverse`, the exporter dispatch arms, the nav-pad pixel), not by driving the percentage up. The number is a search strategy, not the product. So the plan targets **100% of what is worth executing**, and states plainly what is subtracted and why. The subtractions total ~50 lines. ## Phase 0 — Unblock the existing suite (1–2 days) The highest-leverage work in this document. 148 tests already written. 1. Add the `makepad-test` dev-dependency (diff above). Commit the lockfile change with it. 2. Fix the import on line 1 of `tests/ui.rs`. 3. Diagnose the child build failure. Capture the child's stderr from `makepad_test`'s runtime; try the spreadsheet-ui approach of `run_with_config` with `PATH`/`CARGO`/`CARGO_HOME`/`RUSTUP_HOME` injected, in place of the bare `#[makepad_test]` attribute. 4. Triage the failures that remain once the app launches. Expect two populations: tests whose selectors have drifted from the current UI, and tests that need `NIGIG_TEST_MODE` and a loaded project (the file already has `require_project_loaded()` for this). **Exit criteria:** `xvfb-run -a cargo test -p nigig-build --test ui` green, and a CI job running it. Record the pass count and the widget coverage delta — both are the input to every later estimate here. **Expected yield:** unknown, and deliberately not guessed. These tests touch app launch, workspace open/close, tab switching, the spreadsheet and solar panels. If they lift the widget half from 13% to 40% the rest of this plan is much shorter; if they lift it to 18%, Phase 2 grows. Measure before committing to Phases 2–4. ## Phase 1 — Close the engine's remaining 390 lines (3–5 days) Small, well-understood, and it keeps the gated number honest while Phase 0 is in flight. | File | Uncovered | What it is | Verdict | |---|---|---|---| | `arch_pdf.rs` | 152 | printpdf emitter: page furniture, dimension strings, title block | **Cover** with golden-byte tests over a fixed scene | | `commands.rs` | 56 | error arms and one `#[ignore]`d release-only test | **Cover**, except the ignored one | | `cad_scene.rs` | 44 | defensive arms on builder invariants | **Cover** or delete the unreachable ones | | `exporters.rs` | 28 | `ExportTarget::Prompt` — raises a native save dialog | **Subtract** (see below) | | `construction_geometry.rs` | 20 | `other => panic!(...)` arms inside tests | **Subtract**: they execute only when a test fails | | `arch_gltf.rs` | 14 | GLB byte-layout edges | Cover | | `tools.rs` | 11 | tool-state edges | Cover | | `persistence.rs` | 10 | the two real-data-dir wrappers | **Cover** by pointing `HOME`/`XDG_DATA_HOME` at a temp dir | | others | 55 | scattered | Cover | **Exit criteria:** engine ≥ 99.5% with per-file floors raised to match, and the ~48 subtracted lines isolated so the number is honest — see Phase 3. ## Phase 2 — Extract logic the harness can reach (2–3 weeks) For widget code that Phase 0's app-level tests exercise shallowly or not at all. The pattern is proven: `nav_pad.rs` moved 64 lines of hit-testing out of a 630-line event handler into a pure module at 100%, and found a one-pixel bug on the way. Targets, in order of expected defect yield rather than size: 1. `viewport_input.rs` (672 lines) — snap resolution, direct-distance entry parsing, marquee window-vs-crossing, modifier decisions. All pure given a small state struct. 2. `workspace_actions.rs` (690) — action dispatch and validation. 3. `viewport_2d.rs` (138) — screen↔world projection is already pure maths wearing an `impl CadViewport` coat. 4. `viewport_render.rs` (2,083) — geometry generation is separable from the draw calls; the draw calls themselves are not worth extracting. **Calibration from the one data point we have:** ~60 lines per extraction, roughly half a day each including tests. The handler does not get covered — it shrinks. Expect the widget percentage to move slowly; expect the defect yield to be the real return. **Exit criteria:** the four files above under 200 lines of unextracted imperative glue each, every extracted module ≥ 95% with a floor. ## Phase 3 — Make the subtractions explicit (2 days) Because `#[coverage(off)]` is unavailable, the ~48 genuinely unreachable lines have to be handled structurally: - Move `ExportTarget::Prompt`'s dialog arm into `exporters_dialog.rs`, excluded from the report by filename, with the reason in the file header. The arm becomes three lines that call into it. - Leave the in-test `panic!` arms where they are and subtract them in the report generator, which already knows which files are test-only. **Exit criteria:** `tools/test-cad-coverage.sh` prints both a raw and an adjusted figure, and the adjusted one can legitimately be 100%. Anything subtracted is listed by file and line count in `TEST_BASELINE.md`. ## Phase 4 — Gate it (2 days) 1. Add the widget coverage run to CI, report-only for one week, then with floors at measured-minus-one. 2. Raise the engine floor to 99. 3. Add the `--test ui` suite to `full-crate-check` under xvfb. 4. Fix `--test cost_estimator` and `--test cost_estimator_ui`, which still do not compile and are noted as such in `nigig-build.yml`. Another 1,873 lines of tests nobody runs. ## What this does not cover - **The 3D render path.** `viewport_render.rs`'s draw calls can be executed but not meaningfully asserted without golden images, and golden-image testing over a GL context in CI is its own project. - **`script_bindings.rs`'s VM integration** beyond its current 71%. The remaining lines are the makepad script VM boundary. - **Assertion quality.** Nothing here prevents a test that executes a line and checks nothing. The `--test ui` suite in particular is visibility-heavy. Coverage will not tell you; review has to. ## Order of work, and why Phase 0 first because 148 written tests that have never run is a larger and cheaper prize than anything else on the list, and because its result resizes Phases 2–4. Phase 1 in parallel — it is independent, and it keeps the one gated number moving while Phase 0's outcome is unknown. Do not start Phase 2 before Phase 0 reports: extracting logic that the app-level tests already cover would be effort spent for no measurement.