33// The QML-visible surface of all six bank GUI controllers, audited against
44// `gui/qml/` itself.
55//
6- // TEMPORARY BASELINE FORM: no exemptions, so the first run prints the real
7- // backlog. Replaced by the recorded form once the findings are known.
6+ // The bank GUI had no test of any kind before this file: `bank_tests` links
7+ // `bank_lib` and drives the models, and nothing linked `gui/controllers/*` at
8+ // all. So the thirteen `.qml` files under `gui/qml/` and the six `QObject`s
9+ // they bind by string have never been checked against each other. QML binds by
10+ // string, which means a renamed `Q_INVOKABLE`, a `Connections` handler for a
11+ // signal that no longer exists, or a property read resolving to `undefined` is
12+ // not a compile error, not a test failure, and not a QML warning — the pane
13+ // simply stays empty.
14+ //
15+ // This file is that check, and it is not hand-written: it points
16+ // `morph::ladder::testkit::QmlSurfaceAudit`
17+ // (`examples/common/testkit/qml_surface.hpp`) at the GUI's own QML and lets
18+ // those files be the expectation, in both directions. See that header for what
19+ // the audit does and does not cover.
20+ //
21+ // The alias mapping below is the one per-rung fact the audit cannot derive,
22+ // and bank's is the simplest possible shape — for a reason worth stating,
23+ // because it is not the shape the ladder rungs have. `gui/main.cpp` publishes
24+ // each controller with `QQmlContext::setContextProperty`, not
25+ // `setInitialProperties`: they are root-context names, visible under the same
26+ // name in every one of the thirteen files, and no sub-view re-exposes one
27+ // under a property of its own. So one `bind()` per controller covers every
28+ // file, and no `bindIn()` is needed — unlike ledger, whose sub-views each call
29+ // their own bridge plain `bridge`.
30+ //
31+ // bank is not a ladder rung (it is absent from `examples/rungs.txt` and never
32+ // calls `morph_add_rung()`), so this binary is wired by hand in
33+ // `examples/bank/CMakeLists.txt` rather than by the rung glob. It exists only
34+ // in a `-DMORPH_BUILD_BANK_GUI=ON -DMORPH_BUILD_TESTS=ON` configure.
835
936#include < QString>
1037#include < QStringList>
1138#include < catch2/catch_test_macros.hpp>
1239#include < filesystem>
40+ #include < initializer_list>
1341#include < string>
42+ #include < utility>
1443
1544#include " BankClient.hpp"
1645#include " controllers/AccountController.hpp"
@@ -27,7 +56,14 @@ using morph::ladder::testkit::QmlSurfaceAudit;
2756
2857} // namespace
2958
30- TEST_CASE (" bank baseline" , " [bank][gui][qml-surface]" ) {
59+ TEST_CASE (" Every bank controller exposes exactly the surface gui/qml binds, and nothing more" ,
60+ " [bank][gui][qml-surface]" ) {
61+ // A real BankClient, because every controller holds `BridgeHandler`s
62+ // constructed from one. Nothing here dispatches an action — the audit reads
63+ // metaobjects and text — but `BankClient`'s constructor runs the schema
64+ // migrations, so it needs a database like any other bank test does. Its own
65+ // file, not the one `bank_tests` shares, so the two binaries can run
66+ // concurrently.
3167 const auto dbPath = std::filesystem::temp_directory_path () / " morph_bank_qml_surface.db" ;
3268 bankgui::BankClient client{" DRIVER=SQLite3;Database=" + dbPath.string ()};
3369
@@ -46,8 +82,72 @@ TEST_CASE("bank baseline", "[bank][gui][qml-surface]") {
4682 audit.bind (QStringLiteral (" payees" ), payeeController);
4783 audit.bind (QStringLiteral (" loans" ), loanController);
4884
85+ // ── `refresh`, reached only through a `var` array ─────────────────────
86+ // Not a backlog and not a defect: this is the audit's documented blind
87+ // spot, met head on. `AppShell.qml` holds the five page controllers in
88+ //
89+ // readonly property var controllers: [accounts, txns, cards, payees, loans]
90+ //
91+ // and refreshes the visible page with
92+ // `shell.controllers[shell.current].refresh()` (AppShell.qml:12-14). That
93+ // is dynamic member access — the scanner sees an index expression, never
94+ // the name `refresh` against an alias — so every one of these five is
95+ // reachable, exercised on every page switch, and invisible to a text scan.
96+ // `app` is not in that array and has no `refresh`, which is why only five
97+ // controllers appear here.
98+ //
99+ // Recorded rather than worked around: rewriting AppShell to a five-armed
100+ // switch purely so a scanner can see the call would be bending the rung
101+ // around its guard.
102+ const QString dynamic = QStringLiteral (
103+ " called dynamically via AppShell.qml's `controllers[current].refresh()`; "
104+ " dynamic member access is outside what a text scan can see" );
105+ for (const char * alias : {" accounts" , " txns" , " cards" , " payees" , " loans" }) {
106+ audit.allowUnbound (QString::fromLatin1 (alias), QStringLiteral (" refresh" ), dynamic);
107+ }
108+
109+ // ── The pre-existing backlog, recorded rather than swallowed ──────────
110+ // The first run of this audit reported four members these six controllers
111+ // publish that no file under gui/qml/ binds — over and above the five
112+ // dynamic `refresh` calls above. The other direction was clean: no QML file
113+ // binds a name its controller lacks, so no screen is broken. Each is either
114+ // dead surface or a missing control, and deciding which is per-member work
115+ // this file does not do. They are listed here so the guard goes live now
116+ // and catches the *next* drift in either direction, with the backlog
117+ // itemised instead of hidden behind a lowered bar.
118+ //
119+ // The list is checked in both directions too: an exemption for a member
120+ // that has since been deleted, or one QML has since bound, fails this test
121+ // (testkit/qml_surface.hpp). It can only shrink deliberately.
122+ //
123+ // Same shape as ledger's (morph#239), lims' (morph#287) and kanban's
124+ // (morph#291).
125+ const QString backlog = QStringLiteral (" unbound controller surface, tracked in morph#296" );
126+ for (const auto & [alias, member] : std::initializer_list<std::pair<const char *, const char *>>{
127+ // `txns.selectAccount(id)` is called, but the property it writes
128+ // is never read back, so the account picker cannot reflect a
129+ // selection the controller made itself (TransactionController.cpp
130+ // auto-selects the first account on refresh).
131+ {" txns" , " selectedAccount" },
132+ {" txns" , " selectedChanged" },
133+ // Emitted on every deposit/withdraw/transfer, and on every bill
134+ // payment — no QML handles either.
135+ {" txns" , " posted" },
136+ {" payees" , " paid" },
137+ }) {
138+ audit.allowUnbound (QString::fromLatin1 (alias), QString::fromLatin1 (member), backlog);
139+ }
140+
49141 const QStringList findings = audit.run ();
50142 INFO (findings.join (QStringLiteral (" \n " )).toStdString ());
51143 CHECK (findings.isEmpty ());
52- CHECK (audit.scannedFiles () == QStringList{});
144+
145+ // The audit is only as good as the files it found: the thirteen gui/qml
146+ // ships, which is also the list gui/CMakeLists.txt hands qt_add_qml_module.
147+ CHECK (audit.scannedFiles () ==
148+ QStringList{QStringLiteral (" AccountsPage.qml" ), QStringLiteral (" AppButton.qml" ),
149+ QStringLiteral (" AppShell.qml" ), QStringLiteral (" CardsPage.qml" ), QStringLiteral (" Field.qml" ),
150+ QStringLiteral (" LoansPage.qml" ), QStringLiteral (" Login.qml" ), QStringLiteral (" Main.qml" ),
151+ QStringLiteral (" MoveMoneyPage.qml" ), QStringLiteral (" Panel.qml" ),
152+ QStringLiteral (" PayeesPage.qml" ), QStringLiteral (" Picker.qml" ), QStringLiteral (" Pill.qml" )});
53153}
0 commit comments