diff options
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 235 | ||||
| -rw-r--r-- | src/notmuchworker.cpp | 30 | ||||
| -rw-r--r-- | tests/notmuchfixture.h | 33 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 133 |
4 files changed, 419 insertions, 12 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index be52da9..4039753 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -19,8 +19,10 @@ came from one such pass on 2026-08-04 and included two defects that had gone unrecorded here for a while. Items 39 to 45 came from the 2026-08-05 pass, which found one more defect (41, a message body silently dropped by the MIME walk) and one item that cannot be planned at all until the user says where the thing it -manages lives (44). Compare the two at the start of a session; the procedure is -in `CLAUDE.md`. +manages lives (44). Items 121 to 123 came from the 2026-08-20 pass, which found +that item 74 had closed only half of what its note asked for, and that the +README had gone stale enough to document a mandatory config key by omitting it. +Compare the two at the start of a session; the procedure is in `CLAUDE.md`. Numbering is stable. New items append with the next free number and never renumber, so a note referring to "item 7" keeps meaning the same thing. An item @@ -185,6 +187,12 @@ taking that too literally. | 118 | No way to empty the trash from inside the app | workflow | S | open, 2026-08-17. **Blocked on 103**, which creates the trash in the first place. Deliberately left out of 103's spec at the user's request rather than squeezed in | | 119 | The unsynced-changes count cannot be opened to see what it counts | information | S | open, 2026-08-19, from the notes. One of the four things it sums carries no message ids at all, so a list cannot be complete without a change to how the count is kept | +| 121 | The thread list shows nothing while a query is running | feedback | S | open, 2026-08-20, from the notes. Follows item 74, which fixed the status-bar half and left the list itself blank | +| 122 | The README documents a version of the app that no longer exists | documentation | M | open, 2026-08-20, from the notes. Delete-to-trash is entirely undocumented, including a config key a user must now set | +| 123 | Sending mail is not designed | v2 | ? | open, 2026-08-20, from the notes. Brainstorm only, explicitly `#plan-only`; the user places most open UX behind it | + +| 124 | The worker reads the index directory as the mail root | defect | S | open, 2026-08-20. Blocks moving the index to an SSD. Under a split `mail_root`/`path` config, Delete would move mail INSIDE the index directory, where mbsync cannot see it | + Sizes are rough: XS under an hour, S a sitting, M a session. --- @@ -805,6 +813,229 @@ specifically to find out what those were. for the fourth, and the item is not complete without it. +## 121. The thread list shows nothing while a query is running + +**Observed (user, from the notes):** "can we show a spinner in the left panel +while 'Searching' is going? Especially at first run, the loading wait is several +seconds, and the status bar starts updating 'Searching N threads' after the +first have already appeared. Before that the program seems broken." + +**This is the half of item 74 that was never built**, and the note is precise +about which half. Item 74 closed on 2026-08-15 having fixed the status bar, +which used to set "Searching..." once and hold it for the whole walk. The count +the note describes is that fix working as designed: it is written from +`m_model->rowCount()` in `onThreadsReady`, so by construction it cannot report +anything before the first batch has landed. + +**Cause (verified in the code).** `MainWindow::runQuery` clears the model and +sets the status text (`src/mainwindow.cpp:2414`), and nothing else in the view +changes. The thread list is then an empty `QTreeView` until `appendBatch` runs +on the first batch, so **a query in progress and a query that matched nothing +render identically**. There is no busy state on the view at all. + +**The gap is measured, and item 74's numbers understate it badly.** Re-measured +on 2026-08-20 against the user's real inbox, seven minutes after boot, with the +index verifiably unread (0.0% of 1037 MB resident). Item 74's figures came from +`posix_fadvise(POSIX_FADV_DONTNEED)` eviction, which does not reproduce a real +cold boot on this hardware: + +| phase | item 74, 2026-08-11 | measured cold, 2026-08-20 | warm | +|---|---|---|---| +| `search_threads` returns | 411 ms | **673 ms** | 2 ms | +| first batch of 200 rows | 642 ms | **2008 ms** | 12 ms | +| walk complete | 5714 ms | **38618 ms** | 154 ms | +| threads | 4444 | 4628 | 4628 | + +So the list is blank for **two seconds**, and keeps growing for **thirty-eight**, +on 4% more mail. The user's note said "several seconds" and the note was right. + +**The cause is the storage, not the code.** `/data` is `/dev/sda1`, a 7200rpm +platter (`rotational: 1`); warm, the identical walk is 154 ms, a 250x +difference. Item 124 is the prerequisite for moving the index to the NVMe SSD +already in the machine, which would make this gap ~12 ms and reduce this item to +a nicety. + +**Approach.** A busy state on the left pane between `runQuery` and the first +`onThreadsReady`, cleared by whichever of the first batch or `queryFinished` +arrives first. The empty-result case must be distinguishable from it: when +`queryFinished` reports zero, the pane should say so rather than returning to a +blank list, which is the same ambiguity one step later. + +The likely shape is an overlay or a placeholder row rather than a literal +spinner widget, but that is a design question for the user, not a decision to +take here. A spinner also has to be animated by the UI thread, which is free +here since the work is on the worker, but that is worth stating because it is +the usual reason a spinner does not spin. + +**Constraints.** + +- **A background refresh must stay silent.** `onThreadsReady` returns early on + the refresh branch and `onQueryFinished` does the same, deliberately, so a + sync-driven refresh does not flicker the status bar. A busy indicator that + ignored that guard would make every cron sync flash the list. That silence is + already a test, and it should cover this too. +- **Item 74's decision not to address the cold cost was taken on wrong + numbers** and is worth revisiting, though not here. It judged a 5.7 s wait not + worth prefaulting 1.1 GB; the real figure is 38.6 s. The answer is not + prefaulting either way: it is item 124 plus moving the index off the platter. + This item makes the remaining wait legible, nothing more. +- Nothing about the query timing may change. + +**Size: S.** + +## 122. The README documents a version of the app that no longer exists + +**Observed (user, from the notes):** "documentation needs updating, EG the +README.md reports various things not up-to-date anymore." + +**Cause (verified).** `README.md` was last touched on 2026-08-15 by b405e32, +which moved the SlackBuild out to the `my-slackbuilds` repo. Everything released +since then is absent from it. Releases 0.19.0 through 0.26.1 all landed after +that commit. + +**Measured, by grepping both documents for the same terms:** + +| term | README | CHANGELOG | +|---|---|---| +| `trash` | 0 | 14 | +| `restore` | 0 | 7 | +| `Select all` | 0 | 3 | +| `deleted-from` | 0 | 0 | + +**One of these is worse than stale documentation.** Item 103 made a per-account +`trash` key MANDATORY: an account without one produces a config warning, and +Delete cannot work. The README is the only place a user reads about configuring +an account, and it does not mention the key at all. So the documented config +produces a warning against the current binary, and the feature that needs it is +undocumented. The `deleted-from:<folder>` tag is likewise invisible, and a user +who sees it on a message has nowhere to look it up. + +**Approach.** An audit against the changelog rather than a rewrite: walk the +sections from 0.19.0 forward and check each user-visible change for a README +home. The config section and the keyboard-shortcut table are the two most +likely to have drifted, since both enumerate things that have been added to. + +**Constraints.** + +- **The changelog is the evidence, not memory.** Every entry since b405e32 is + written down; work from it. +- **`### Upgrading` sections are the priority.** They exist precisely because a + user's config or habits had to change, and those are the paragraphs whose + absence from the README costs the user a broken setup rather than a moment of + confusion. +- The "Development Approach" section at the bottom is required by the user's + global preference and must survive any edit. +- No personal details, per the same preference: account names in examples stay + generic. + +**Size: M.** The audit is most of it; the writing is small once the list exists. + +## 123. Sending mail is not designed + +**Observed (user, from the notes):** "we should start brainstorming sending +emails. Most of the open issues are UX. #v2 #plan-only #new-branch". + +**Not a defect and not implementation work.** The note tags it `#plan-only`, and +this repo's own scope statement agrees: `CLAUDE.md` records that v1 is +read-and-organize only and that compose and send are v2. Recorded here so the +backlog stops being silent about the largest thing the user has written down. + +**What it blocks.** Item 72 (khard/khal) is explicitly placed after send by the +user's own note, and cannot be specified before it. The completion machinery +that would serve recipient completion already exists as `QueryCompleter`, and +the `QLineEdit::setCompleter` trap in `CLAUDE.md` applies directly to any +multi-recipient field, so there is prior art to reuse rather than a blank page. + +**What it does NOT change.** The architecture note that this application does +**no network protocol work at all** is load-bearing: fetching is `mbsync` via +`assets/mailsync.sh`, and sending should be an external script on the same +model, not an SMTP client written here. A design that puts a socket in this +process is out of scope regardless of how the UI turns out. + +**Approach.** Brainstorm first, on its own branch, producing a spec under +`docs/superpowers/specs/` before any code. The open questions are all UX, as the +note says: where a composer lives, how a draft is stored so `notmuch` can see +it, what reply and forward do to the thread the user is looking at, and how a +queued message reaches the sending script. + +**Constraints.** + +- **Ask the user before designing.** They have not said what they pictured, and + this is the item where guessing costs the most. +- Drafts are already visible to the app: item 67 counts them in the placeholder + pane, so a draft folder is configured and indexed. +- `#new-branch` is the user's own tag on it. + +**Size: `?`** until the brainstorm has happened. It is the largest open item by +some distance. + +## 124. The worker reads the index directory as the mail root + +**Observed (measured, 2026-08-20):** not reported from use. Found while +measuring item 121's cold-start cost, which established that the 1.1 GB notmuch +index sits on a 7200rpm platter (`/dev/sda1`, `rotational: 1`) while an NVMe SSD +sits idle in the same machine. Moving the index to the SSD is a notmuch +configuration change and needs no code, but qtmaildir does not survive it. + +**Cause (verified against notmuch 0.39 and a throwaway database).** notmuch +supports splitting the index from the mail with two keys: + +```ini +[database] +mail_root=/data/Mail +path=/home/you/.local/share/notmuch +``` + +Under that layout `notmuch_database_get_path()` returns the **index** +directory, not the mail root. Measured on a split test database: + +| accessor | legacy (`path` only) | split | +|---|---|---| +| `notmuch_database_get_path()` | mail root | **index dir** | +| `notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT)` | mail root | mail root | + +`notmuchworker.cpp` calls `get_path()` at six sites and treats every one as the +mail root: lines 297, 755, 900, 901, 1062, and the `relativeFilePath()` calls +at 364, 380 and 918 that consume them. + +**One of those six moves mail, and it is the dangerous one.** +`moveMessages` composes its destination as +`root + "/" + destFolder + "/cur"` (`src/notmuchworker.cpp:755`). Under a split +config `root` is the index directory, so Delete would move the message into +`<index>/Trash/cur`: outside the Maildir, invisible to mbsync, and gone from +every other client. That is item 103's stranded-mail failure with a new cause, +and this repo has already shipped that class of bug once. + +The rest degrade rather than destroy. `relativeFilePath()` against the wrong +root yields `../../../data/Mail/account/cur/...` instead of `account/cur/...`, +so no path matches an account prefix and every row resolves to no account, +which is exactly what the comment at line 294 already warns about. + +**Approach.** Replace `notmuch_database_get_path()` with +`notmuch_config_get(m_db, NOTMUCH_CONFIG_MAIL_ROOT)` at the six sites. + +**No conditional is needed, and that is the point.** `MAIL_ROOT` returns the +mail root under BOTH layouts, verified above: under a legacy `path`-only config +it equals `get_path()`, so the change is a no-op against the current +configuration and correct against the split one. A fallback to `get_path()` when +`MAIL_ROOT` is NULL is the tempting belt-and-braces addition and should be +resisted unless a NULL is actually observed, since it reintroduces the wrong +answer on the path where it matters. + +**Constraints.** + +- **`moveMessages` is the site to test hardest.** A wrong root there reaches the + mail server, per the "Delete MOVES the file" note in `CLAUDE.md`. +- The test fixture must build a database whose index is NOT inside the mail + root, or it cannot tell the two accessors apart: under the ordinary fixture + layout both return the same string and a mutation stays green. +- `notmuch_config_get` returns a string owned by notmuch (`notmuch.h:2585`); + do not free it. +- Nothing about the legacy layout may change. The migration is the user's to + perform, separately, once this ships. + +**Size: S.** Six call sites and a fixture that can tell them apart. + ## Deferred, unsized, or split out Items noted while triaging but not part of the original list. Same numbering diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index 66b408c..d0274cd 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -35,6 +35,28 @@ namespace { +/// Where the MAIL lives, which is not always where the index lives. +/// +/// Item 124. notmuch can be configured with `mail_root` and `path` as separate +/// keys, which is how the Xapian index moves to faster storage while the +/// Maildir stays put. Under that layout `notmuch_database_get_path()` returns +/// the INDEX directory, so every path composed from it lands in the wrong tree: +/// message paths resolve to `../..` escapes that match no account, and a move +/// writes into the index instead of the Maildir, where mbsync never sees it. +/// +/// `NOTMUCH_CONFIG_MAIL_ROOT` is correct under BOTH layouts. With only `path` +/// set it returns that same directory, so this is not a special case for split +/// configurations but the right question to ask in every one. Measured against +/// notmuch 0.39: legacy config, `get_path()` and `MAIL_ROOT` agree; split +/// config, only `MAIL_ROOT` names the Maildir. +/// +/// The string is owned by notmuch and must not be freed (notmuch.h:2585). +QString mailRootOf(notmuch_database_t *db) +{ + const char *root = notmuch_config_get(db, NOTMUCH_CONFIG_MAIL_ROOT); + return root ? QString::fromUtf8(root) : QString(); +} + QStringList tagsOf(notmuch_message_t *message) { QStringList result; @@ -295,7 +317,7 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation, // database-relative prefix: comparing the two never matched and left every // row resolving to no account at all. const QString dbRoot = - QDir(QString::fromUtf8(notmuch_database_get_path(m_db))).absolutePath(); + QDir(mailRootOf(m_db)).absolutePath(); QVector<ThreadSummary> batch; batch.reserve(kBatchSize); @@ -752,7 +774,7 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds, return; } - const QString root = QString::fromUtf8(notmuch_database_get_path(db)); + const QString root = mailRootOf(db); const QString destDir = root + QLatin1Char('/') + destFolder + QStringLiteral("/cur"); @@ -898,7 +920,7 @@ void NotmuchWorker::resolveQuery(const QString &query, // ThreadSummary::firstMessagePath: the UI knows accounts only by their // maildir, itself a database-relative prefix. const QString dbRoot = - QDir(QString::fromUtf8(notmuch_database_get_path(m_db))).absolutePath(); + QDir(mailRootOf(m_db)).absolutePath(); QStringList messageIds; QStringList paths; @@ -1059,7 +1081,7 @@ void NotmuchWorker::requestFolders() if (!openReadOnly()) return; - const QString root = QString::fromUtf8(notmuch_database_get_path(m_db)); + const QString root = mailRootOf(m_db); if (root.isEmpty()) { emit errorOccurred( QStringLiteral("notmuch reports no database path.")); diff --git a/tests/notmuchfixture.h b/tests/notmuchfixture.h index bb25526..d1bdf62 100644 --- a/tests/notmuchfixture.h +++ b/tests/notmuchfixture.h @@ -44,6 +44,23 @@ public: QString configPath() const { return m_dir.filePath(QStringLiteral("config")); } QString maildirPath() const { return m_dir.filePath(QStringLiteral("mail")); } + /// Where the Xapian index lives. Equal to maildirPath()/.notmuch in the + /// ordinary layout; a directory of its own once splitIndex() is called. + QString indexPath() const + { + return m_splitIndex ? m_dir.filePath(QStringLiteral("index")) + : maildirPath() + QStringLiteral("/.notmuch"); + } + + /// Puts the index OUTSIDE the mail root, as notmuch's `mail_root`/`path` + /// split does (item 124). + /// + /// This is opt-in because it is the only layout that can tell + /// `notmuch_database_get_path()` apart from the mail root: in the ordinary + /// layout the two return the same string, so a test written against it + /// passes whichever accessor the code uses. Call before index(). + void splitIndex() { m_splitIndex = true; } + /// Writes one message into <folder>/cur (or new/ when unread). /// /// Returns false if the file could not be written. Call index() afterwards. @@ -103,9 +120,18 @@ public: return false; } QTextStream out(&config); - out << "[database]\n" - << "path=" << maildirPath() << "\n" - << "[new]\n" + out << "[database]\n"; + if (m_splitIndex) { + // Two keys: the mail stays put and only the index moves. notmuch + // reads `path` as the database directory ITSELF here, not as a + // parent to create `.notmuch` in. + QDir().mkpath(indexPath()); + out << "mail_root=" << maildirPath() << "\n" + << "path=" << indexPath() << "\n"; + } else { + out << "path=" << maildirPath() << "\n"; + } + out << "[new]\n" << "tags=unread;inbox;\n"; out.flush(); config.close(); @@ -132,4 +158,5 @@ public: private: QTemporaryDir m_dir; QString m_error; + bool m_splitIndex = false; }; diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index b5da31a..998696f 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -92,6 +92,10 @@ private slots: void moveMessagesReportsOnlyWhatMoved(); void moveMessagesGivesTheFileAFreshMaildirName(); void moveMessagesKeepsTheMaildirFlags(); + + void aSplitIndexStillResolvesTheMailRoot(); + void aSplitIndexMovesIntoTheMaildirNotTheIndex(); + void aSplitIndexListsTheMaildirsFolders(); void twoMessagesMovedTogetherGetDistinctNames(); private: @@ -101,7 +105,8 @@ private: bool addMovableMessage(const QString &folder, const QString &messageId); /// The single file backing `messageId`, or an empty string when the /// database does not know the id. - QString fileOf(const QString &messageId); + QString fileOf(const QString &messageId, + const QString &configPath = QString()); /// Tags of one message, read back through a fresh worker query. QStringList tagsOf(const QString &messageId); @@ -210,9 +215,10 @@ bool TestNotmuchWorker::addMovableMessage(const QString &folder, return m_fixture.index(); } -QString TestNotmuchWorker::fileOf(const QString &messageId) +QString TestNotmuchWorker::fileOf(const QString &messageId, + const QString &configPath) { - NotmuchWorker worker(m_fixture.configPath()); + NotmuchWorker worker(configPath.isEmpty() ? m_fixture.configPath() : configPath); QSignalSpy loaded(&worker, &NotmuchWorker::threadLoaded); worker.loadThread(QStringLiteral("{id:%1}").arg(messageId), QString(), 1); if (loaded.isEmpty()) @@ -1377,5 +1383,126 @@ void TestNotmuchWorker::moveMessagesReportsOnlyWhatMoved() QCOMPARE(inTrash.size(), 1); } + +// Item 124. notmuch can put the Xapian index outside the mail root +// (`mail_root` + `path`), which is how the index moves to faster storage while +// the mail stays put. Under that layout `notmuch_database_get_path()` returns +// the INDEX directory, so any code treating it as the mail root composes paths +// into the wrong tree entirely. +// +// These three need `splitIndex()`, and that is the whole point: in the +// ordinary layout the index lives inside the mail root and both accessors +// return the same string, so a test written against it passes whichever one +// the code uses and a mutation stays green. + +void TestNotmuchWorker::aSplitIndexStillResolvesTheMailRoot() +{ + NotmuchFixture fixture; + QVERIFY(fixture.isValid()); + fixture.splitIndex(); + QVERIFY(fixture.addMessage(QStringLiteral("work/INBOX"), + QStringLiteral("split1@example.org"), + QStringLiteral("Something"), + QStringLiteral("Alice <alice@example.org>"), + QStringLiteral("Mon, 1 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"), false)); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + // The fixture really did split them, or the rest proves nothing. + QVERIFY2(!fixture.indexPath().startsWith(fixture.maildirPath()), + "the fixture did not put the index outside the mail root"); + QVERIFY(QDir(fixture.indexPath() + QStringLiteral("/xapian")).exists()); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy ready(&worker, &NotmuchWorker::threadsReady); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.runQuery(QStringLiteral("*"), 1, NotmuchWorker::NewestFirst, false); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + QCOMPARE(ready.size(), 1); + + const auto threads = ready.first().at(0).value<QVector<ThreadSummary>>(); + QCOMPARE(threads.size(), 1); + + // The path is stored relative to the MAIL ROOT. Resolved against the index + // directory it comes back as a "../.." escape, which matches no account + // prefix and leaves every row belonging to no account at all. + const QString path = threads.first().firstMessagePath; + QVERIFY2(!path.startsWith(QStringLiteral("..")), + qPrintable(QStringLiteral("path escaped the mail root: %1").arg(path))); + QVERIFY2(path.startsWith(QStringLiteral("work/INBOX/")), + qPrintable(QStringLiteral("expected a work/INBOX path, got: %1").arg(path))); +} + +void TestNotmuchWorker::aSplitIndexMovesIntoTheMaildirNotTheIndex() +{ + NotmuchFixture fixture; + QVERIFY(fixture.isValid()); + fixture.splitIndex(); + QVERIFY(fixture.addMessage(QStringLiteral("work/INBOX"), + QStringLiteral("split2@example.org"), + QStringLiteral("Doomed"), + QStringLiteral("Alice <alice@example.org>"), + QStringLiteral("Mon, 1 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"), false)); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy moved(&worker, &NotmuchWorker::messagesMoved); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.moveMessages({ QStringLiteral("split2@example.org") }, + QStringLiteral("work/Trash")); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + QCOMPARE(moved.size(), 1); + + // The file must land in the MAILDIR's trash. Composed against the index + // directory it lands inside the Xapian tree instead: outside the Maildir, + // invisible to mbsync, and gone from every other client. That is item + // 103's stranded mail with a new cause, which is why this assertion names + // the index directory explicitly rather than only checking the good path. + const QString expected = + fixture.maildirPath() + QStringLiteral("/work/Trash/cur"); + QVERIFY2(!QDir(fixture.indexPath() + QStringLiteral("/work")).exists(), + "the move created a folder inside the INDEX directory"); + + const QString after = + fileOf(QStringLiteral("split2@example.org"), fixture.configPath()); + QVERIFY2(!after.isEmpty(), "the message is not in the database after the move"); + QCOMPARE(QFileInfo(after).absolutePath(), expected); + QVERIFY2(QFile::exists(after), qPrintable(after)); +} + +void TestNotmuchWorker::aSplitIndexListsTheMaildirsFolders() +{ + NotmuchFixture fixture; + QVERIFY(fixture.isValid()); + fixture.splitIndex(); + QVERIFY(fixture.addMessage(QStringLiteral("work/INBOX"), + QStringLiteral("split3@example.org"), + QStringLiteral("Something"), + QStringLiteral("Alice <alice@example.org>"), + QStringLiteral("Mon, 1 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"), false)); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy folders(&worker, &NotmuchWorker::foldersReady); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.requestFolders(); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + QCOMPARE(folders.size(), 1); + + // Scanned from the mail root. Scanned from the index directory the list is + // empty, or worse, names Xapian's own subdirectories as mail folders. + const QStringList found = folders.first().at(0).toStringList(); + QVERIFY2(found.contains(QStringLiteral("work/INBOX")), + qPrintable(QStringLiteral("expected work/INBOX, got: %1") + .arg(found.join(QStringLiteral(", "))))); + QVERIFY2(!found.contains(QStringLiteral("xapian")), + "the index's own directory was listed as a mail folder"); +} + QTEST_MAIN(TestNotmuchWorker) #include "test_notmuchworker.moc" |
