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
62 changes: 59 additions & 3 deletions examples/lims/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,29 @@ submitted by an explicit button, following bookmarks' and pastebin's
precedent: a bound form auto-submits the moment its required fields are
engaged, which for a lab reading would file a result mid-keystroke.

### Two channels carry a refusal, and both are bound

A refusal is this rung's product, not its exception path — 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 are each a statement about the measurement that
the analyst has to read. So both surfaces bind both channels the bridges have:

- **`failed` / `lastError`** carries the *typed* invokables' errors —
`openSample`, `refresh`, the zero-field transitions, `verifyResult`. It is
bound as the red label in `Main.qml` and at the foot of `ResultEntryView.qml`.
- **`replyReceived(actionType, ok, payload)`** carries every *schema-driven*
form's outcome, both ways, with the model's own `what()` as the payload when
`ok` is false. `submitIfValid` is the only path those six forms have and it
never routes through `failed`, so each view handles `replyReceived` in a
`Connections` block — the same shape bookmarks, pastebin and polls use — and
clears a form only once the submission was actually accepted.

`test_lims_qml_surface.cpp` asserts the second half rather than assuming it:
one case runs the QML-surface audit unexempted and requires that no finding
names `replyReceived`, so deleting either `Connections` block fails the build's
tests instead of quietly emptying the screen.

### Two handlers on the lifecycle surface, and why

`SampleModel` is keyed, so the handler every attached action runs on is
Expand All @@ -621,9 +644,42 @@ to a key**, and `RegisterClient`/`RegisterSample` carry none — dispatching
either on it fails with "handler not bound". This was confirmed empirically
here before `SamplePresenter` grew a second, plain handler for exactly those
two actions; `polls::gui::PollPresenter` reached the same conclusion for
`CreatePoll`. `registerSample` therefore does two dispatches: create on the
plain handler, then attach the shared one to the id that came back. `busy()`
never dips between them.
`CreatePoll`. `RegisterSample` needs no such help even though it too arrives
before any key exists: it is result-keyed, so `BridgeHandler::execute`'s
`ResultKeyed` branch runs it on an anonymous instance and promotes that
instance to the id the result names before the completion resolves — one
dispatch, and the shared handler is attached when it returns.

That asymmetry is why the two registration invokables survive the surface trim
below while the other typed calls do not. `submitIfValid` routes both
registration actions to the *plain* handler, so through the form path
`RegisterClient` never sets the `clientId` property and `RegisterSample` never
leaves the shared handler attached — the invokables are the only dispatch that
does either, which is exactly what their exemptions in
`test_lims_qml_surface.cpp` say.

### One dispatch path per action (morph#287)

Both bridges published a typed invokable *and* a schema-driven form for the
same action: `registerClient`/`registerSample`/`rejectSample`/
`returnForRework` beside `submitIfValid("RegisterClient")` and its three
siblings, and `captureReading`/`captureQualifier`/`resolveConflict` beside
`submitIfValid("CaptureConcentration")` and `submitIfValid("ResolveConflict")`.
No QML called the typed half. Two paths to one action is two places for the
behaviour to differ, and here they already did: `captureReading` took the
reading as a `double` and converted it with `Concentration::fromDouble` at the
field's declared precision, so it *rounded* an over-precise reading that the
form path submits exactly and the model refuses (decision 7) — and it was the
only `double` on this rung's QML surface, against the convention
`gui_lib/lims_qml_conversions.hpp` states for a `Quantity`.

So the redundant half is deleted, together with the outcome signals only it
emitted (`resultCaptured`, `conflictResolved`), the `sampleAttached` signal
whose state `Main.qml` re-reads through `refreshResults()` instead, and the
`bound` relay neither view handles. `rejectSample` and `returnForRework` are
gone from `SamplePresenter` too, since nothing else called them. What survives
is one path per action, and a QML-surface exemption list of two entries, each
naming a mechanism rather than a backlog.

### What the smoke test does not prove, and what covers it instead

Expand Down
54 changes: 54 additions & 0 deletions examples/lims/gui/qml/ResultEntryView.qml
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,64 @@ Item {
/// The served `{actionType: schema}` document, parsed once.
readonly property var schemas: page.resultBridge ? JSON.parse(page.resultBridge.schemasJson) : ({})

/// The last schema-driven submission's outcome, as one line of text.
property string reply: ""
property bool replyIsError: false

function report(message, isError) {
page.reply = message
page.replyIsError = isError
}

// Both forms on this screen submit through `submitIfValid`, whose replies
// arrive here rather than on `failed` (which carries only the typed
// invokables' errors). This is the screen where that matters most: an
// over-precise reading (§7), an `exactlyOneOf` violation the renderer let
// through (§6), an unknown qualifier or dilution code (§18) and a refused
// conflict resolution are all server refusals of a *capture*, and each of
// them is a statement about the measurement that the analyst has to see.
Connections {
target: page.resultBridge

function onReplyReceived(actionType, ok, payload) {
if (!ok) {
page.report(actionType + ": " + payload, true)
return
}
page.report(actionType + " ok", false)
// Only an accepted submission is cleared — a refused reading keeps
// what was typed, so it can be corrected rather than re-entered.
if (actionType === "CaptureConcentration")
captureForm.resetFields()
else if (actionType === "ResolveConflict")
resolveForm.resetFields()
}

// `verifyResult` is a typed call, so its outcome does not arrive on
// `replyReceived`; its failures reach the label at the foot of this
// file through `failed`, and this is the other half — a recorded
// verification changes the row's `verifiedBy`, and re-reading the
// listing is how this surface learns any of its own state (the same
// re-read `submitIfValid`'s success arm does in the presenter). Not a
// second copy of the row: one decoder, not two that could disagree.
function onResultVerified(verification) {
page.report("verified result " + verification.resultId, false)
page.resultBridge.refreshResults()
}
}

ColumnLayout {
anchors.fill: parent
spacing: 8

Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
visible: text !== ""
color: page.replyIsError ? "red" : "black"
text: page.reply
}

RowLayout {
Layout.fillWidth: true

Expand Down
50 changes: 50 additions & 0 deletions examples/lims/gui/qml/SampleView.qml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,60 @@ Item {
/// The served `{actionType: schema}` document, parsed once.
readonly property var schemas: page.sampleBridge ? JSON.parse(page.sampleBridge.schemasJson) : ({})

/// The last schema-driven submission's outcome, as one line of text.
property string reply: ""
property bool replyIsError: false

function report(message, isError) {
page.reply = message
page.replyIsError = isError
}

// Every form on this screen submits through `submitIfValid`, and
// `submitIfValid` reports *both* outcomes on `replyReceived` — not on
// `failed`, which carries only the typed invokables' errors. So this, and
// not the `lastError` label in Main.qml, is where a refused registration,
// rework or rejection arrives. Without it the operator clicks a button and
// the screen does not change, which on a rung whose subject is refusals is
// the entire story going missing.
//
// The same shape bookmarks' BookmarkListView.qml, pastebin's Main.qml and
// polls' VoteView.qml use, for the same reason.
Connections {
target: page.sampleBridge

function onReplyReceived(actionType, ok, payload) {
if (!ok) {
page.report(actionType + ": " + payload, true)
return
}
page.report(actionType + " ok", false)
// Only a form that was actually accepted is cleared: a refused
// submission keeps what the operator typed, so the correction is
// an edit rather than a re-entry.
if (actionType === "RegisterClient")
clientForm.resetFields()
else if (actionType === "RegisterSample")
sampleForm.resetFields()
else if (actionType === "ReturnForRework")
reworkForm.resetFields()
else if (actionType === "RejectSample")
rejectForm.resetFields()
}
}

ColumnLayout {
anchors.fill: parent
spacing: 8

Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
visible: text !== ""
color: page.replyIsError ? "red" : "black"
text: page.reply
}

GroupBox {
Layout.fillWidth: true
title: "Register"
Expand Down
60 changes: 4 additions & 56 deletions examples/lims/gui_lib/result_presenter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
#include <string>
#include <utility>

#include "gui/error_text.hpp"

namespace lims::gui {

ResultPresenter::ResultPresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent)
Expand All @@ -17,19 +19,7 @@ ResultPresenter::ResultPresenter(::morph::bridge::Bridge& bridge, ::morph::exec:
trackBound(_catalog.whenBound());
}

void ResultPresenter::reportError(const std::exception_ptr& err) {
try {
std::rethrow_exception(err);
} catch (const std::exception& ex) {
emit failed(QString::fromStdString(ex.what()));
}
}

void ResultPresenter::dispatchCapture(CaptureConcentration action) {
track<ResultView>(
_sample.execute(std::move(action)), [this](ResultView view) { emit resultCaptured(std::move(view)); },
[this](const std::exception_ptr& err) { reportError(err); });
}
void ResultPresenter::reportError(const std::exception_ptr& err) { emit failed(::morph::ladder::gui::errorText(err)); }

void ResultPresenter::submitIfValid(const QString& actionType, const QString& bodyJson) {
static const QStringList kOwned{QStringLiteral("CaptureConcentration"), QStringLiteral("ResolveConflict")};
Expand All @@ -49,11 +39,7 @@ void ResultPresenter::submitIfValid(const QString& actionType, const QString& bo
refreshConflicts();
},
[this, actionType](const std::exception_ptr& err) {
try {
std::rethrow_exception(err);
} catch (const std::exception& ex) {
emit replyReceived(actionType, false, QString::fromStdString(ex.what()));
}
emit replyReceived(actionType, false, ::morph::ladder::gui::errorText(err));
});
}

Expand All @@ -71,31 +57,6 @@ void ResultPresenter::openSample(SampleId sampleId) {
[this](const std::exception_ptr& err) { reportError(err); });
}

void ResultPresenter::captureReading(AnalysisVersionId versionId, double reading, const QString& dilution,
double factor) {
CaptureConcentration action{.analysisVersionId = versionId,
// Exact at the declared precision -- see this
// class's own doc comment for why the double
// QML hands over does not cost exactness here.
.value = Concentration::fromDouble(reading)};
if (!dilution.isEmpty()) {
action.dilution = DilutionChoice{dilution.toStdString()};
// Only stamped for a diluted preparation. Sending it regardless would
// be harmless (the model ignores a factor whose preparation says
// neat -- the rung README's §5 clear-on-hide decision) but it would
// put a number in the journal that never applied to anything.
if (dilution.toStdString() == std::string{kDilutionDiluted}) {
action.dilutionFactor = DilutionFactor::fromDouble(factor);
}
}
dispatchCapture(std::move(action));
}

void ResultPresenter::captureQualifier(AnalysisVersionId versionId, const QString& code) {
dispatchCapture(
CaptureConcentration{.analysisVersionId = versionId, .qualifier = QualifierChoice{code.toStdString()}});
}

void ResultPresenter::refreshResults() {
track<ListResultsResult>(
_sample.execute(ListResults{}), [this](ListResultsResult result) { emit resultsListed(std::move(result)); },
Expand All @@ -116,17 +77,4 @@ void ResultPresenter::refreshConflicts() {
[this](const std::exception_ptr& err) { reportError(err); });
}

void ResultPresenter::resolveConflict(ConflictId conflictId, const QString& resolution, const QString& note) {
// The two-value choice is spelled as a string at the QML boundary and
// mapped to the enum here -- translation, which is this layer's whole job.
// Anything that is not "apply" is a discard: the safe half of the pair,
// since discarding leaves the server's own value standing.
const auto decision =
resolution == QStringLiteral("apply") ? ConflictResolution::ApplyAnyway : ConflictResolution::DiscardStale;
track<ConflictView>(
_sample.execute(ResolveConflict{.conflictId = conflictId, .resolution = decision, .note = note.toStdString()}),
[this](ConflictView view) { emit conflictResolved(std::move(view)); },
[this](const std::exception_ptr& err) { reportError(err); });
}

} // namespace lims::gui
56 changes: 13 additions & 43 deletions examples/lims/gui_lib/result_presenter.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,19 @@ namespace lims::gui {
/// key-less `SampleModel` action here to be defeated by an `AllowShared`
/// handler's "not bound until attached" rule.
///
/// @par Where the exactness lives
/// QML has one numeric type and it is a `double`, so a typed reading arrives
/// here as one. `Concentration::fromDouble` converts it at the field's
/// **declared** precision (`Quantity<mg_per_L, 3>`), which is exact for any
/// decimal with at most that many places: scaling by 10^3 and rounding
/// removes the binary representation error rather than propagating it. That
/// is also what makes the displayed value and the stored value the same
/// number — the entry point rounds visibly, at the precision the schema
/// advertises, instead of storing digits nobody ever sees. A hand-built
/// payload that skips this path and carries more precision is rejected by the
/// model (the rung README's §3 decision 7); this path cannot produce one.
/// @par Where the exactness lives: not here
/// A reading never crosses this class as a `double`. Capture is submitted as
/// a schema-driven form body -- `submitIfValid("CaptureConcentration", ...)`
/// -- so the number travels as the exact rational the shipped renderer built
/// from the served `x-decimalPlaces`, and the model checks that precision
/// against the analysis version's own declaration. An earlier
/// `captureReading(versionId, double, ...)` entry point converted the value
/// with `Concentration::fromDouble` at the field's declared precision, which
/// was exact but *rounding*: it made the model's over-precision refusal (the
/// rung README's §3 decision 7) unreachable from the GUI, and it put the only
/// `double` on this rung's QML surface, against the convention
/// `lims_qml_conversions.hpp` states. It was deleted rather than documented
/// (morph#287).
class ResultPresenter : public ::morph::ladder::gui::Presenter {
Q_OBJECT
public:
Expand All @@ -64,21 +66,6 @@ class ResultPresenter : public ::morph::ladder::gui::Presenter {
/// @param sampleId The sample to attach to.
void openSample(SampleId sampleId);

/// @brief Captures a measured reading. Emits `resultCaptured`, or
/// `failed`.
/// @param versionId The analysis version captured under.
/// @param reading The reading in mg/L, as QML supplies it.
/// @param dilution `"neat"`, `"diluted"`, or empty for "not stated".
/// @param factor The dilution factor; ignored unless @p dilution is
/// `"diluted"`, and required by the action's own rules when it is.
void captureReading(AnalysisVersionId versionId, double reading, const QString& dilution, double factor);

/// @brief Captures a non-reading — one of the three "no number" claims.
/// Emits `resultCaptured`, or `failed`.
/// @param versionId The analysis version captured under.
/// @param code `"notMeasured"`, `"belowLOD"` or `"aboveUDL"`.
void captureQualifier(AnalysisVersionId versionId, const QString& code);

/// @brief Lists the attached sample's results. Emits `resultsListed`, or
/// `failed`.
void refreshResults();
Expand All @@ -92,13 +79,6 @@ class ResultPresenter : public ::morph::ladder::gui::Presenter {
/// sample. Emits `conflictsListed`, or `failed`.
void refreshConflicts();

/// @brief Records a human's decision about one flagged conflict. Emits
/// `conflictResolved`, or `failed`.
/// @param conflictId The conflict to resolve.
/// @param resolution `"discard"` or `"apply"`.
/// @param note The resolver's stated rationale. Required.
void resolveConflict(ConflictId conflictId, const QString& resolution, const QString& note);

/// @brief Dispatches @p bodyJson as @p actionType's body — the
/// schema-driven path the shipped `DynamicForm` submits through.
///
Expand All @@ -123,9 +103,6 @@ class ResultPresenter : public ::morph::ladder::gui::Presenter {
/// @brief `OpenSample` succeeded on this surface's own handler.
/// @param view The attached sample.
void sampleAttached(lims::SampleView view);
/// @brief A capture succeeded.
/// @param view The stored result.
void resultCaptured(lims::ResultView view);
/// @brief `ListResults` succeeded.
/// @param result The attached sample's results.
void resultsListed(lims::ListResultsResult result);
Expand All @@ -135,9 +112,6 @@ class ResultPresenter : public ::morph::ladder::gui::Presenter {
/// @brief `ListConflicts` succeeded.
/// @param result The attached sample's flagged conflicts.
void conflictsListed(lims::ListConflictsResult result);
/// @brief `ResolveConflict` succeeded.
/// @param view The conflict in its resolved state.
void conflictResolved(lims::ConflictView view);
/// @brief Any action's typed error, as `std::exception::what()`.
/// @param message Ready for direct display.
void failed(QString message);
Expand All @@ -148,10 +122,6 @@ class ResultPresenter : public ::morph::ladder::gui::Presenter {
/// @param err The failed completion's exception.
void reportError(const std::exception_ptr& err);

/// @brief Dispatches @p action and emits `resultCaptured` with its result.
/// @param action The capture to dispatch.
void dispatchCapture(CaptureConcentration action);

#ifndef Q_MOC_RUN
::morph::bridge::BridgeHandler<AnalysisCatalogModel> _catalog;
::morph::bridge::BridgeHandler<SampleModel, ::morph::bridge::AllowShared> _sample;
Expand Down
Loading
Loading