Skip to content
Closed
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
33 changes: 27 additions & 6 deletions examples/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,18 +134,39 @@ CHECK(findings.isEmpty());
property-bag keys inside an emitted `QVariantMap` (no metaobject exists for
them — the per-rung "bag shape" cases remain the only guard); QML the rung
does not own, such as the shipped `MorphForms` renderer's; dynamic member
access; and whether the shell wires an alias to the class the test bound.
access (`bank`'s `AppShell.qml` calls `controllers[current].refresh()`
through a `var` array, so five reachable invokables sweep as unreferenced);
inherited members, in the sweep direction only; and whether the shell wires
an alias to the class the test bound.

`allowUnbound(alias, member, reason)` exempts one member, with a required
reason. The exemption list is itself audited — a member that has since been
deleted, an alias nobody bound, or a member QML does bind is a finding — so it
can only shrink deliberately.

Adopted by `bookmarks`, `pastebin`, `polls` and `ledger`; `lims`, `kanban`
and `bank` are tracked in morph#240. The audit's own
mutation suite is `examples/common/testkit/test_qml_surface.cpp`: every case
there drives it against a deliberately broken pair and asserts the specific
finding.
Adopted by every rung that has QML — `bookmarks`, `pastebin`, `polls`,
`ledger`, `lims` and `kanban` — and by `examples/bank`, which is not a rung
(morph#240). The audit's own mutation suite is
`examples/common/testkit/test_qml_surface.cpp`: every case there drives it
against a deliberately broken pair and asserts the specific finding.

`bank` is where the two shell shapes diverge, and its adoption is worth
reading before adding a seventh. It publishes its controllers with
`QQmlContext::setContextProperty` rather than `setInitialProperties`, so its
aliases are root-context names: one `bind()` per controller covers all
thirteen files, no `bindIn()` is needed, and its `Connections` blocks name
their target as a bare identifier (`target: app`). A bare target counts only
when it is an alias the audit was handed — an identifier that is not is far
more often a local `id`, and nothing distinguishes the two — so the
unbound-bridge check does not reach this shape. `bank` also has a shared
controller base: a reference written in QML resolves against everything the
bridge can reach, inherited members included, while the unreferenced-member
sweep covers only what the bound class declares itself.

`bank` is not built by CI at all today (only its WebAssembly GUI is, in
`wasm-demo.yml`), so `bank_gui_tests` — like `bank_tests` beside it — runs
only locally, in a `-DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_BANK_GUI=ON`
configure.

## The dual-mode fixture

Expand Down
46 changes: 46 additions & 0 deletions examples/bank/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -127,5 +127,51 @@ if(MORPH_BUILD_TESTS)
list(APPEND CMAKE_MODULE_PATH ${Catch2_DIR})
include(Catch)
catch_discover_tests(bank_tests DISCOVERY_MODE PRE_TEST)

# ── bank_gui_tests: the GUI's own suite ──────────────────────────────
# A second binary rather than more sources in bank_tests: bank_tests
# links bank_lib and nothing Qt, and it is built in every
# -DMORPH_BUILD_BANK_EXAMPLE=ON configure, including the ones with no
# Qt at all. The controllers only exist when MORPH_BUILD_BANK_GUI=ON
# (that is the option that adds gui/), so anything holding one has to
# be gated on the same switch.
#
# tests/gui/, not tests/: bank lists its test sources by hand rather
# than globbing them, so a file in tests/ that is deliberately absent
# from bank_tests reads as an oversight. The subdirectory says which
# binary a file belongs to.
if(TARGET bank_gui_lib)
add_executable(bank_gui_tests
tests/gui/test_bank_qml_surface.cpp
# QmlSurfaceAudit, compiled in rather than linked from
# morph::ladder_testkit. That library is only created by
# MORPH_BUILD_LADDER=ON, an option entirely independent of
# MORPH_BUILD_BANK_EXAMPLE — bank is not a ladder rung (it is
# absent from examples/rungs.txt and never calls
# morph_add_rung), so requiring the ladder's whole shared
# testkit to be configured before the bank GUI can be tested
# would couple two option trees that have nothing else to do
# with each other. It would also over-link: morph_ladder_testkit
# pulls in morph::qt, Qt6::WebSockets, ladder_gui and ladder_app,
# while the audit itself needs Qt6::Core and nothing more.
# This is the same translation unit, not a copy of it, so the
# audit still has exactly one implementation.
${PROJECT_SOURCE_DIR}/examples/common/testkit/qml_surface.cpp
)
target_include_directories(bank_gui_tests PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/tests
${PROJECT_SOURCE_DIR}/examples/common)
target_link_libraries(bank_gui_tests PRIVATE bank_gui_lib Catch2::Catch2WithMain)
target_compile_features(bank_gui_tests PRIVATE cxx_std_23)
# The audit reads gui/qml/*.qml out of the source tree at run time,
# and ctest runs the binary from the build directory. Same macro,
# same value (the repo root) and same reason as every ladder rung's
# test binary gets from morph_add_rung().
target_compile_definitions(bank_gui_tests PRIVATE
MORPH_LADDER_SOURCE_ROOT="${PROJECT_SOURCE_DIR}")
apply_bigobj(bank_gui_tests)

catch_discover_tests(bank_gui_tests DISCOVERY_MODE PRE_TEST)
endif()
endif()
endif()
19 changes: 19 additions & 0 deletions examples/bank/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ cmake --build build --target bank_gui

Structure:

- **`gui/bank_gui_lib`** — a static library holding `BankClient` and every
controller, linking `Qt6::Core` only (no Quick, no Qml). `bank_gui` is
`main.cpp` plus the QML module on top of it; `bank_gui_tests` (see
[Tests](#tests)) is the other consumer.
- **`gui/BankClient`** — owns the worker pool, a `morph::qt::QtExecutor`, the
`Bridge` (local backend), DB setup, and the session. UI-toolkit-agnostic.
- **`gui/controllers/`** — one QObject controller per domain (`AppController`,
Expand Down Expand Up @@ -240,6 +244,21 @@ shared on-disk test database. Notable cross-cutting tests:
- `test_offline.cpp` — parks deposits in an `InMemoryOfflineQueue` while "offline" and
replays them via `SyncWorker` on "reconnect".

`tests/gui/` is a second binary, `bank_gui_tests`, built only when
`-DMORPH_BUILD_BANK_GUI=ON` is also set — `bank_tests` links no Qt at all and
is built in configures that have none. It holds `test_bank_qml_surface.cpp`,
which points the ladder testkit's `QmlSurfaceAudit`
(`examples/common/testkit/qml_surface.hpp`, `examples/TESTING.md`) at the six
controllers and the thirteen `.qml` files, and fails on any name that exists on
one side only: a renamed `Q_INVOKABLE`, a `Connections` handler for a signal
that is gone, a property read that would resolve to `undefined`. None of those
is a compile error or a QML warning; the pane just stays empty.

```sh
cmake --build build --target bank_gui_tests
./build/examples/bank/bank_gui_tests
```

## Status

Models, tests, CLI, the Qt 6 GUI, and a self-contained WebAssembly build (hosted
Expand Down
33 changes: 30 additions & 3 deletions examples/bank/gui/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,22 @@ find_package(Qt6 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2)

qt_standard_project_setup(REQUIRES 6.5)

qt_add_executable(bank_gui
main.cpp
# ── bank_gui_lib: the QML-facing QObject layer ───────────────────────────────
# Split out of bank_gui so something other than the desktop client can link the
# controllers. bank_gui keeps only main.cpp (the QGuiApplication, the context
# properties and the screenshot smoke path); everything a test could want to
# hold an instance of lives here.
#
# Qt6::Core only, deliberately: not one controller — nor BankClient — includes
# a Quick or Qml header. They trade in QObject/QString/QVariantList and a morph
# Bridge, so the surface audit in ../tests/gui/ links this without dragging in a
# QML engine, matching the ladder's own gui_lib convention
# (cmake/morph_add_rung.cmake, "ladder_<rung>_gui_lib": Qt6::Core, no Catch2).
#
# AUTOMOC, because every controller carries Q_OBJECT. moc is kept away from the
# heavy morph/Lightweight headers by the #ifndef Q_MOC_RUN guards in the
# controller headers themselves — see the README's "Qt GUI" section.
add_library(bank_gui_lib STATIC
BankClient.cpp
controllers/BankController.cpp
controllers/AppController.cpp
Expand All @@ -17,6 +31,19 @@ qt_add_executable(bank_gui
controllers/PayeeController.cpp
controllers/LoanController.cpp
)
target_include_directories(bank_gui_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(bank_gui_lib PUBLIC bank_lib Qt6::Core)
target_compile_features(bank_gui_lib PUBLIC cxx_std_23)
set_target_properties(bank_gui_lib PROPERTIES AUTOMOC ON)
apply_bigobj(bank_gui_lib)
# Note: deliberately NOT calling apply_warnings() here, for the same reason
# bank_lib does not — this target includes the third-party ORM headers
# transitively and they are not -Werror clean.

# ── bank_gui: the desktop client ─────────────────────────────────────────────
qt_add_executable(bank_gui
main.cpp
)

qt_add_qml_module(bank_gui
URI BankGui
Expand All @@ -38,6 +65,6 @@ qt_add_qml_module(bank_gui
)

target_include_directories(bank_gui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(bank_gui PRIVATE bank_lib Qt6::Quick Qt6::Qml Qt6::QuickControls2)
target_link_libraries(bank_gui PRIVATE bank_gui_lib Qt6::Quick Qt6::Qml Qt6::QuickControls2)
target_compile_features(bank_gui PRIVATE cxx_std_23)
apply_bigobj(bank_gui)
153 changes: 153 additions & 0 deletions examples/bank/tests/gui/test_bank_qml_surface.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// SPDX-License-Identifier: Apache-2.0
//
// The QML-visible surface of all six bank GUI controllers, audited against
// `gui/qml/` itself.
//
// The bank GUI had no test of any kind before this file: `bank_tests` links
// `bank_lib` and drives the models, and nothing linked `gui/controllers/*` at
// all. So the thirteen `.qml` files under `gui/qml/` and the six `QObject`s
// they bind by string have never been checked against each other. QML binds by
// string, which means a renamed `Q_INVOKABLE`, a `Connections` handler for a
// signal that no longer exists, or a property read resolving to `undefined` is
// not a compile error, not a test failure, and not a QML warning — the pane
// simply stays empty.
//
// This file is that check, and it is not hand-written: it points
// `morph::ladder::testkit::QmlSurfaceAudit`
// (`examples/common/testkit/qml_surface.hpp`) at the GUI's own QML and lets
// those files be the expectation, in both directions. See that header for what
// the audit does and does not cover.
//
// The alias mapping below is the one per-rung fact the audit cannot derive,
// and bank's is the simplest possible shape — for a reason worth stating,
// because it is not the shape the ladder rungs have. `gui/main.cpp` publishes
// each controller with `QQmlContext::setContextProperty`, not
// `setInitialProperties`: they are root-context names, visible under the same
// name in every one of the thirteen files, and no sub-view re-exposes one
// under a property of its own. So one `bind()` per controller covers every
// file, and no `bindIn()` is needed — unlike ledger, whose sub-views each call
// their own bridge plain `bridge`.
//
// bank is not a ladder rung (it is absent from `examples/rungs.txt` and never
// calls `morph_add_rung()`), so this binary is wired by hand in
// `examples/bank/CMakeLists.txt` rather than by the rung glob. It exists only
// in a `-DMORPH_BUILD_BANK_GUI=ON -DMORPH_BUILD_TESTS=ON` configure.

#include <QString>
#include <QStringList>
#include <catch2/catch_test_macros.hpp>
#include <filesystem>
#include <initializer_list>
#include <string>
#include <utility>

#include "BankClient.hpp"
#include "controllers/AccountController.hpp"
#include "controllers/AppController.hpp"
#include "controllers/CardController.hpp"
#include "controllers/LoanController.hpp"
#include "controllers/PayeeController.hpp"
#include "controllers/TransactionController.hpp"
#include "testkit/qml_surface.hpp"

namespace {

using morph::ladder::testkit::QmlSurfaceAudit;

} // namespace

TEST_CASE("Every bank controller exposes exactly the surface gui/qml binds, and nothing more",
"[bank][gui][qml-surface]") {
// A real BankClient, because every controller holds `BridgeHandler`s
// constructed from one. Nothing here dispatches an action — the audit reads
// metaobjects and text — but `BankClient`'s constructor runs the schema
// migrations, so it needs a database like any other bank test does. Its own
// file, not the one `bank_tests` shares, so the two binaries can run
// concurrently.
const auto dbPath = std::filesystem::temp_directory_path() / "morph_bank_qml_surface.db";
bankgui::BankClient client{"DRIVER=SQLite3;Database=" + dbPath.string()};

bankgui::AppController appController{client};
bankgui::AccountController accountController{client};
bankgui::TransactionController transactionController{client};
bankgui::CardController cardController{client};
bankgui::PayeeController payeeController{client};
bankgui::LoanController loanController{client};

QmlSurfaceAudit audit{QStringLiteral(MORPH_LADDER_SOURCE_ROOT "/examples/bank/gui/qml")};
audit.bind(QStringLiteral("app"), appController);
audit.bind(QStringLiteral("accounts"), accountController);
audit.bind(QStringLiteral("txns"), transactionController);
audit.bind(QStringLiteral("cards"), cardController);
audit.bind(QStringLiteral("payees"), payeeController);
audit.bind(QStringLiteral("loans"), loanController);

// ── `refresh`, reached only through a `var` array ─────────────────────
// Not a backlog and not a defect: this is the audit's documented blind
// spot, met head on. `AppShell.qml` holds the five page controllers in
//
// readonly property var controllers: [accounts, txns, cards, payees, loans]
//
// and refreshes the visible page with
// `shell.controllers[shell.current].refresh()` (AppShell.qml:12-14). That
// is dynamic member access — the scanner sees an index expression, never
// the name `refresh` against an alias — so every one of these five is
// reachable, exercised on every page switch, and invisible to a text scan.
// `app` is not in that array and has no `refresh`, which is why only five
// controllers appear here.
//
// Recorded rather than worked around: rewriting AppShell to a five-armed
// switch purely so a scanner can see the call would be bending the rung
// around its guard.
const QString dynamic = QStringLiteral(
"called dynamically via AppShell.qml's `controllers[current].refresh()`; "
"dynamic member access is outside what a text scan can see");
for (const char* alias : {"accounts", "txns", "cards", "payees", "loans"}) {
audit.allowUnbound(QString::fromLatin1(alias), QStringLiteral("refresh"), dynamic);
}

// ── The pre-existing backlog, recorded rather than swallowed ──────────
// The first run of this audit reported four members these six controllers
// publish that no file under gui/qml/ binds — over and above the five
// dynamic `refresh` calls above. The other direction was clean: no QML file
// binds a name its controller lacks, so no screen is broken. Each is either
// dead surface or a missing control, and deciding which is per-member work
// this file does not do. They are listed here so the guard goes live now
// and catches the *next* drift in either direction, with the backlog
// itemised instead of hidden behind a lowered bar.
//
// The list is checked in both directions too: an exemption for a member
// that has since been deleted, or one QML has since bound, fails this test
// (testkit/qml_surface.hpp). It can only shrink deliberately.
//
// Same shape as ledger's (morph#239), lims' (morph#287) and kanban's
// (morph#291).
const QString backlog = QStringLiteral("unbound controller surface, tracked in morph#296");
for (const auto& [alias, member] : std::initializer_list<std::pair<const char*, const char*>>{
// `txns.selectAccount(id)` is called, but the property it writes
// is never read back, so the account picker cannot reflect a
// selection the controller made itself (TransactionController.cpp
// auto-selects the first account on refresh).
{"txns", "selectedAccount"},
{"txns", "selectedChanged"},
// Emitted on every deposit/withdraw/transfer, and on every bill
// payment — no QML handles either.
{"txns", "posted"},
{"payees", "paid"},
}) {
audit.allowUnbound(QString::fromLatin1(alias), QString::fromLatin1(member), backlog);
}

const QStringList findings = audit.run();
INFO(findings.join(QStringLiteral("\n")).toStdString());
CHECK(findings.isEmpty());

// The audit is only as good as the files it found: the thirteen gui/qml
// ships, which is also the list gui/CMakeLists.txt hands qt_add_qml_module.
CHECK(audit.scannedFiles() ==
QStringList{QStringLiteral("AccountsPage.qml"), QStringLiteral("AppButton.qml"),
QStringLiteral("AppShell.qml"), QStringLiteral("CardsPage.qml"), QStringLiteral("Field.qml"),
QStringLiteral("LoansPage.qml"), QStringLiteral("Login.qml"), QStringLiteral("Main.qml"),
QStringLiteral("MoveMoneyPage.qml"), QStringLiteral("Panel.qml"),
QStringLiteral("PayeesPage.qml"), QStringLiteral("Picker.qml"), QStringLiteral("Pill.qml")});
}
Loading
Loading