Skip to content

lims: give a refused form submission a path to the operator, and leave one dispatch path per action - #314

Merged
Yaraslaut merged 3 commits into
masterfrom
fix/lims-error-surface
Aug 26, 2026
Merged

lims: give a refused form submission a path to the operator, and leave one dispatch path per action#314
Yaraslaut merged 3 commits into
masterfrom
fix/lims-error-surface

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

The defect

Every failed form submission in the lims GUI was silently swallowed. The presenters emit replyReceived(actionType, false, what()) on the error arm; the bridges forward it but feed lastError only from failed(); and QML bound replyReceived nowhere.

So the operator clicked Submit and got nothing back for an over-precise reading (decision 7), an exactlyOneOf violation (decision 6), a four-eyes refusal (decision 16), an unknown qualifier code (decision 18), or a conflict-resolution refusal — the rung's entire thesis.

Pinned first, by running the surface audit with zero exemptions so the finding is structural rather than hand-asserted:

lims::gui::SampleBridge::replyReceived is a signal no scanned .qml handles
lims::gui::ResultBridge::replyReceived is a signal no scanned .qml handles

The fix shape, and why

replyReceived is bound in the two QML views rather than folding ok == false into lastError. Every other rung with a replyReceived binds it in QML (bookmarks/gui/qml/BookmarkListView.qml:184, pastebin/gui/qml/Main.qml:96, polls/gui/qml/VoteView.qml:98). Folding into lastError would discard the action name and the success half, leave replyReceived needing a permanent exemption, and give the views nowhere to clear a form only once it was accepted. Ledger's lastError-only shape is not a counter-example — ledger has no replyReceived.

#287 — reduced from 15 exemptions to 2, and the issue's pre-sort was backwards

Do not act on #287 as written. It calls rejectSample/returnForRework/resolveConflict "missing controls" — the controls exist as schema-driven forms — and calls replyReceived "plumbing to delete", which is the fix above.

Deleted as genuinely redundant second dispatch paths: rejectSample, returnForRework, captureReading, captureQualifier, resolveConflict, plus bound on both bridges and three signals whose only emitters were those calls.

Deleting captureReading also removes the only double on the QML surface. It took double reading, double factor and converted with Concentration::fromDouble at the declared precision — silently rounding a reading the form path submits exactly and the model refuses, making decision 7's refusal unreachable from the GUI.

Three members the corrected pre-sort still got wrong, each verified rather than trusted:

  • resultVerified is not dead. Its emitter verifyResult is QML-bound, and nothing refreshed the table afterwards — a recorded four-eyes verification never appeared until someone pressed Refresh. Deleting it would have cemented that. Bound instead.
  • registerClient is not redundant. It is the only dispatch emitting clientRegistered, hence the only thing that sets the clientId that SampleView.qml:77 displays. Reproduced: after a form-path registration, clientId() is still -1.
  • registerSample is not redundant. It is the only dispatch that leaves the shared handler attached. Reproduced: the follow-up GetSample fails with handler not bound.

Those last two are the surviving exemptions, and they are keyed to #309 — the schema-driven registration path is broken. Fixing that is what takes #287 to zero.

Also

Both presenters now route errors through the shared morph::ladder::gui::errorText. lims was the only GUI rung not using it, and its four hand-rolled handlers had no catch (...) arm — in a Completion error callback that takes the process down rather than reaching the user.

Verification

ladder_lims_tests: 830 assertions / 128 cases (baseline on master: 821 / 126). ctest -L ladder-lims: 100% of 128. Clean under -Weverything -Werror; clang-format clean; qmllint warning count identical to a stashed baseline.

Not run: coverage, sanitizers, non-lims targets.

⚠️ Expect a newly-visible error after this lands

This branch makes #309's failures visible. Form-path registration will now show a red handler not bound where it previously failed silently. That is correct behaviour surfacing a pre-existing defect — not a regression introduced here — but it will read as one if #309 is not fixed close behind.

Part of #304 (§A3). Reduces #287 to two exemptions; #309 closes it.

🤖 Generated with Claude Code

Yaraslaut and others added 3 commits August 26, 2026 18:25
Every schema-driven form on both lims surfaces submits through
`submitIfValid`, which reports *both* outcomes on one signal --
`replyReceived(actionType, ok, payload)`, with the model's own `what()`
as the payload when `ok` is false. The bridges relayed it faithfully.
No file under `gui/qml/` handled it.

The `failed`/`lastError` pair the two views *do* bind is fed only from
`SamplePresenter::reportError` / `ResultPresenter::reportError`, which
the typed invokables use and `submitIfValid` never touches. So the
operator clicked Submit, the model refused, and the screen did not
change: an over-precise reading (decision 7), an `exactlyOneOf`
violation (decision 6), a four-eyes refusal (decision 16), an unknown
qualifier or dilution code (decision 18) and a rejected conflict
resolution all landed nowhere. Those refusals are the rung's thesis.

Pinned first, at the level that can observe it. `test_lims_qml_surface.cpp`
gains a case that runs the QML-surface audit with no exemptions and
requires that no finding names `replyReceived`; before this change it
failed with exactly the two lines the defect predicts:

    lims::gui::SampleBridge::replyReceived is a signal no scanned .qml
    handles (bound as 'sampleBridge')
    lims::gui::ResultBridge::replyReceived is a signal no scanned .qml
    handles (bound as 'resultBridge')

Fixed by binding `replyReceived` in the two views, not by routing
`ok == false` into `lastError` inside the bridges. That is the shape
every other rung with a `replyReceived` uses -- bookmarks'
`BookmarkListView.qml`, pastebin's `Main.qml` and polls' `VoteView.qml`
each carry the same `Connections { function onReplyReceived(...) }`
block -- and it is the only one of the two that keeps what the signal
carries: which action replied, whether it succeeded, and the payload.
Folding a failure into `lastError` would discard the action name and the
success half, leave `replyReceived` permanently unbound (so the audit
would need a forever-exemption for it, which is the opposite of what
morph#287 is for), and give the two views no place to clear a form only
once its submission was actually accepted.

`test_lims_qml_bridges.cpp` gains the matching bridge-level regression:
a real model refusal (rework on a `registered` sample) driven through
`submitIfValid` comes back as `replyReceived(type, false, message)`
carrying the model's own words, not a generic failure. The two
`replyReceived` entries leave the morph#287 backlog list, which the
audit itself enforces -- an exemption for a member QML does bind is a
finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both bridges published a typed `Q_INVOKABLE` *and* a schema-driven form
for the same action. No QML called the typed half: `SampleView.qml`
submits RegisterClient/RegisterSample/ReturnForRework/RejectSample
through `submitIfValid` (lines 71, 91, 176, 191) and
`ResultEntryView.qml` submits CaptureConcentration/ResolveConflict the
same way (lines 99, 200). Two paths to one action is two places for the
behaviour to differ, and here they already did.

`captureReading(qlonglong, double, QString, double)` is the case worth
naming. It converted the reading with `Concentration::fromDouble` at the
field's *declared* precision, so it silently rounded an over-precise
reading that the form path submits exactly and the model refuses (README
decision 7) -- the refusal was unreachable from the GUI through it. It
was also the only `double` on this rung's QML surface, directly against
the convention `gui_lib/lims_qml_conversions.hpp` states: a `Quantity`
crosses as `valueText` + `hasValue`, never as a number a binding could
round.

Deleted, therefore:

  SampleBridge   bound, rejectSample, returnForRework
  ResultBridge   bound, sampleAttached, resultCaptured, conflictResolved,
                 captureReading, captureQualifier, resolveConflict

plus `SamplePresenter::rejectSample`/`returnForRework`,
`ResultPresenter::captureReading`/`captureQualifier`/`dispatchCapture`/
`resolveConflict` and the two presenter signals only those emitted --
nothing else called any of them.

`resultVerified` is *not* in that class, and the issue's pre-sort had it
wrong: its emitter `verifyResult` is bound by QML
(ResultEntryView.qml:201), so deleting the signal would have left a live
control whose success nothing reports. The view now handles it and
re-reads the listing, which is how the four-eyes verification reaches the
table at all -- previously the row stayed unchanged until somebody
pressed "Refresh results".

The exemption list shrinks from 15 to 2, and neither survivor is a
backlog entry. `SamplePresenter::submitIfValid` routes both registration
actions to the plain, key-less `_creator` handler, so through the form
path `RegisterClient` never emits `clientRegistered` (leaving the
`clientId` property SampleView.qml binds at -1) and `RegisterSample`
never leaves the *shared* handler attached (the follow-up `GetSample`
fails with "handler not bound"). Both were reproduced against the real
bridges before the exemptions were written; both are stated as
mechanisms a test can falsify, and the audit reports the exemption as
stale the moment either stops being true.

The two capture cases in `test_lims_qml_bridges.cpp` now drive
`submitIfValid` with a `glz::write_json` body -- the path the screen
uses -- and take their quiescence from `resultsListed`, which is also
what pins Main.qml's own `openSample` + `refreshResults` ordering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lims was the last GUI rung still hand-rolling this: bookmarks, kanban,
ledger, pastebin and polls all call `morph::ladder::gui::errorText`, and
these four handlers were the same `try { rethrow } catch (const
std::exception&)` three-liner with **no `catch (...)` arm**.

That omission is not cosmetic. Per `examples/common/gui/error_text.hpp`,
these bodies run as `Completion` error callbacks, so an exception that is
not a `std::exception` escaping one unwinds out of the callback rather
than reaching the user -- the process goes down instead of the screen
showing a message. `errorText` handles that arm (and a null
`exception_ptr`) and is covered by its own suite,
`examples/common/testkit/test_error_text.cpp`.

Four sites, two per presenter: `reportError` (the typed invokables'
channel) and `submitIfValid`'s error arm (the schema-driven forms'
channel, which the views now handle -- see the previous two commits).
The visible text is unchanged for a `std::exception`: `what()`, decoded
as UTF-8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
examples/lims/gui_lib/result_presenter.cpp 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant