Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
/build-wasm/
/out/
*.db
# Every ladder rung's server writes its FileActionLog into the *current
# directory* -- `std::filesystem::current_path() / "<rung>_actions.jsonl"`, see
# each `src/server/main.cpp`. Running one from the repo root (or pointing the
# scenario runner at it) therefore drops a journal here, and such a journal was
# once staged by accident on a working branch. Nothing matching this pattern is
# in this repository's history -- the rule exists so nothing ever is.
*_actions.jsonl
*.profraw
/.cache/
/compile_commands.json
Expand Down
32 changes: 21 additions & 11 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -416,17 +416,27 @@ component_management:
# ledger's and lims's above, and `informational: true` like every other
# ladder component (morph#133).
#
# Unlike those two, this entry does NOT yet carry a per-miss audit
# establishing which of the 148 uncovered lines are unreachable-by-design
# rather than merely untested. One thing the measurement already shows is
# not a coverage artifact, and is recorded here rather than rounded past:
# `ContactModel::attachActionLog` and `SavedViewModel::attachActionLog`
# are called by no test at all (both headers score 0/4), so those two
# models' journaling paths are entirely unverified -- while
# AccountModel, LeadModel, OpportunityModel and QuoteModel each have an
# explicit "journals its edits against the attached identity" test. That
# is the same shape of gap ledger's own first measurement turned up
# (a documented obligation nothing executed), and it is open, not closed.
# Re-measured after morph#403 and morph#426, on a profile with zero foreign
# roots: 1488/1616 = 92.08% lines, of which quote_model.cpp is now
# 222/238 93.28% (GetQuote, previously driven by nothing). By Codecov's own
# arithmetic -- which is what this target is checked against, and which
# counts a line with a one-sided branch as a partial rather than a hit --
# the component reads 87.15%: 1390 hits, 128 misses, 77 partials.
#
# The per-miss audit this entry used to lack is now written, in
# examples/crm/README.md's "What the uncovered lines in crm's models are".
# Its result is that crm is unlike ledger and lims: 92 of the 141 lines it
# classified are the rung's own ValidationError/NotFound rejection paths on
# 25 actions, reachable from the wire and simply untested, and only 4 are
# unreachable-by-design. That is not a ceiling and must not be recorded as
# one (morph#412).
#
# `ContactModel::attachActionLog` and `SavedViewModel::attachActionLog` are
# no longer called by no test: both models gained the "journals its edits
# against the attached identity" case their four siblings had, and no
# defect was found on either -- both journal both mutations, under the
# attached key, with the caller's principal. Recorded as a negative result
# against the lims precedent, rather than left implying an open finding.
- component_id: crm
name: "application ladder rung 7 (examples/crm models)"
paths:
Expand Down
111 changes: 111 additions & 0 deletions examples/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,117 @@ here as though it already existed, but it has never been built (morph#255):
(there is no scheduled workflow in this repo yet), so nothing in the
ladder should assume a cadence the rest of the project doesn't have.

## What `examples/common`'s coverage number measures (audit, 2026-09)

`codecov.yml`'s `ladder` component scores `examples/common/**` against a 95%
target. That number was reported as 90.39% on `master` and read as a five-point
regression (morph#411). It is a real figure, it is reproducible, and the
regression is real — but two things about it have to be said before the figure
means anything, because both of them were being read the other way.

### Two numbers, both correct, four points apart

`llvm-cov report`'s line coverage and Codecov's component percentage are not
the same measurement. Codecov reads the uploaded LCOV and scores
`hits / (hits + misses + partials)`, where a *partial* is a line that executed
but carries a branch nothing took. `llvm-cov` counts that line as covered.
Measured on the same profile, on the tree this section was written against:

| | `llvm-cov` lines | Codecov component |
|------------------------------|------------------|-------------------|
| `examples/common` | 96.94% | 93.68% |

Whenever the two are quoted side by side without saying which is which, the
gap looks like a regression or a contradiction. It is neither: it is 39 lines
with a one-sided branch. **Quote the Codecov figure when arguing about the
component's target, because that is the one the target is checked against.**

Reproduce either from a coverage run — `cmake --preset clang-coverage` (plus
`-DMORPH_BUILD_{NET,OFFLINE_SQLITE,QT,LADDER}=ON`), `ctest`, then
`bash scripts/coverage.sh`. `llvm-cov`'s figure is in that script's own report;
Codecov's arithmetic runs over `build/clang-coverage/coverage.lcov`.

A prior local measurement of this component disagreed with the uploaded one by
five points for a *different* reason — a shared compiler cache served objects
built in another worktree, whose absolute paths matched none of
`scripts/coverage.sh`'s relative filters, so the records were dropped rather
than mis-attributed (morph#426). That is fixed at configure time and gated by
`scripts/check_coverage_roots.sh`; run it before trusting any local figure.

### Where the drop came from: four files that did not exist at the measurement

`codecov.yml` records `examples/common` at 95.85% when rungs 2-4 entered the
report (morph#142, 2026-08-21). Splitting today's figure by whether a file
existed then answers the question that ticket asked and could not answer:

| | hits | miss | partial | lines | Codecov |
|------------------------------------|-----:|-----:|--------:|------:|--------:|
| files present at that measurement | 658 | 17 | 12 | 687 | 95.78% |
| `qml_surface.cpp`, `process_pool.hpp`, `journey.hpp`, `step_executor.hpp` | 505 | 49 | 40 | 594 | 85.02% |

The code that was measured at 95.85% still measures 95.78%. **Nothing
regressed.** The component's denominator nearly doubled, and every line of the
growth is testkit written to serve a later rung and not itself tested to the
standard the rest of the directory holds. `qml_surface.cpp` alone was 493 of
the 1281 lines and 70 of the 118 non-hits.

### The artifact set, re-derived

`codecov.yml` names seven lines as measurement artifacts and puts the ceiling
at 478/485 = 98.56%. That list is still correct about the lines it names and is
no longer the whole set — the tree it was written against was a third of
today's size. Re-derived, and split by *why* each line reads uncovered:

**Measurement artifacts — the statement ran; the counter on it did not.**
llvm-cov puts a "control reached past this block" counter on certain closing
braces, and there is no `LCOV_EXCL_LINE` equivalent to suppress one.

* `backend_rig.hpp` 218, 225, 255 — the three switch-case `}` after `break;`,
one per `Mode`. The three the current comment names.
* `strand_interleaver.hpp` 71, 97 — `step()` and `runSchedule()`, whose one
statement calls a `std::function<void()>`. The two the current comment names;
in today's LCOV the zero lands on the `task();` line itself rather than the
brace below it.
* `step_executor.hpp` 89 — the same shape in `runOne()`. **New**: the file
postdates the comment.
* `qml_surface.cpp` 599 — a switch-case `}` after `break;`. **New**, same
reason.

**Unreachable by design — the guard cannot fire through the callers it has.**

* `backend_rig.hpp` 256, 267 — the `default:` label and its `throw`. Present
only because `-Wswitch-default` demands one on a switch that already covers
every enumerator; the file says so at the site. **New.**
* `fault_proxy.cpp` 68-69 — `onClientConnection`'s null guard. The one the
current comment names; `isValidIncomingConnection` is unit-tested directly.
* `qml_surface.cpp` 92-93, 96-97 — `signalNameOf`'s "not in handler shape"
guards. Both call sites match `on[A-Z]\w*`, so the name it is handed can
never be too short, unprefixed, or lower-cased at position 2. **New.**
* `qml_surface.cpp` 353-354 — `record()`'s empty-name early return,
unreachable for exactly that reason. **New.**

**Untested, and reachable.** These are not artifacts and are not excused:

* `qml_surface.cpp` 55, 83-84 — the unbalanced-`{` and unterminated-`(`
fallbacks, reachable only from QML that does not parse.
* `qml_surface.cpp` 441-443 — the "cannot read" finding for a `.qml` file
`QDirIterator` lists but `QFile::open` refuses. Needs a permission-denied
file, which does not survive a git checkout and does not reproduce for a
root-running CI container.
* `fault_proxy.cpp` 17-19 — `~FaultProxy`'s client-socket teardown; every
test today closes the leg before the proxy dies.
* `journey.hpp` 69-72 — the two `catch` arms around a journey step. Pinning
them needs a harness that can assert a Catch2 `FAIL` *happened*, which this
suite does not have.
* `process_pool.hpp` 51-53 — `~ClientProcess`'s kill-if-still-running.
* `process_pool.hpp` 129-130 — `spawn()`'s failed-to-start arm.

**Dead API — no caller anywhere in the tree.** Not a coverage problem; the
lines are uncovered because nothing wants them:

* `process_pool.hpp` 67 — `ClientProcess::waitForFinished`.
* `process_pool.hpp` 160-166 — `ProcessPool::killAll`.

## Framework gaps this strategy exposed (all since closed)

This list was written as candidate issues to schedule. **Every item on it has
Expand Down
37 changes: 37 additions & 0 deletions examples/common/testkit/test_event_poller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@
// fire, deterministically and without any sleep_for (examples/TESTING.md,
// "Pumping discipline -- no sleeps").

#include <QString>
#include <atomic>
#include <catch2/catch_test_macros.hpp>
#include <chrono>
#include <condition_variable>
#include <exception>
#include <memory>
#include <morph/core/backend.hpp>
#include <morph/core/bridge.hpp>
Expand Down Expand Up @@ -699,3 +701,38 @@ TEST_CASE("EventPoller tolerates an empty onFatalError callback on a non-timeout
CHECK(poller.fatalErrorReported());
CHECK_FALSE(poller.running());
}

// ═════════════════════════════════════════════════════════════════════════
// The two failure-classifier helpers, called directly (morph#411)
// ═════════════════════════════════════════════════════════════════════════
//
// `isClientTimeout` and `describeFailure` are declared in
// `event_poller.hpp`'s `detail` namespace with a documented answer for a null
// `std::exception_ptr` and for a throw that is not a `std::exception`. Both
// answers were reachable only through `handleError`, which never produces
// either shape, so what the header promises was held up by nothing. They are
// free functions taking an `exception_ptr`, so the promise can simply be
// called.

TEST_CASE("isClientTimeout: a null exception is not a timeout", "[testkit][event-poller]") {
// The distinction this classifier draws decides whether the poller retries
// or stops for good. "No exception information" must fall on the *stop*
// side: a tick that failed for a reason nobody captured is not evidence
// the transport is merely slow.
CHECK_FALSE(morph::ladder::gui::detail::isClientTimeout(nullptr));
CHECK_FALSE(
morph::ladder::gui::detail::isClientTimeout(std::make_exception_ptr(std::runtime_error{"not a timeout"})));
CHECK(morph::ladder::gui::detail::isClientTimeout(std::make_exception_ptr(morph::backend::ClientTimeoutError{})));
}

TEST_CASE("describeFailure: every input yields a non-empty message", "[testkit][event-poller]") {
// `onFatalError`'s message is the only account a GUI gets of why polling
// stopped, and the header promises it is never empty. The two arms that
// make that true — a null exception and a throw that is not a
// `std::exception` — are the ones no poller path produces.
CHECK(morph::ladder::gui::detail::describeFailure(nullptr).contains(QStringLiteral("no exception information")));
CHECK(morph::ladder::gui::detail::describeFailure(std::make_exception_ptr(42))
.contains(QStringLiteral("non-std::exception")));
CHECK(morph::ladder::gui::detail::describeFailure(std::make_exception_ptr(std::runtime_error{"boom"})) ==
QStringLiteral("boom"));
}
Loading
Loading