diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-24 18:23:50 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-24 18:23:50 +0200 |
| commit | 8743f4828d8ce31879b56338c284b72757530548 (patch) | |
| tree | 0fddc1512b46b13a2df050e58b62313a0e970c42 | |
| parent | 58f13ad9d78a07aab1d683462834a2493078744d (diff) | |
| download | qtmaildir-8743f4828d8ce31879b56338c284b72757530548.tar.gz qtmaildir-8743f4828d8ce31879b56338c284b72757530548.zip | |
feat(compose): offer Edit on a draft in the message pane's bar
Item 157, the half item 153 did not close. A draft was editable by
double-click and by a Message-menu entry, neither of which is where the
user looks while reading one. populateMessageBar() swaps the reply pair
for edit_draft on a displayed draft.
Three things came out of hand-testing it, each invisible to the tests
written before them.
The bar keyed on currentIndex(), which a query leaves valid on a row of
the discarded result, so it kept the draft button after switching to the
inbox and the reply pair after switching to drafts. This is item 150's
trap one level up. It answers from m_currentMessageId/m_currentThreadId
now, which every blanking route clears, refilled from
showPlaceholderPane(), the one site all five of those routes share.
That exposed a defect predating the bar: updateComposeActions() ran only
from the two selection handlers, so Reply and Forward stayed enabled over
a blank pane. Invisible while they sat on the main toolbar among
always-on actions.
The bar is hidden over an empty pane, so it comes and goes with the
subject and the details button rather than hovering over the logo. That
in turn broke the showing half: setBarActions() runs before showThread()
fills m_items, so the first message opened after a blanking left the bar
hidden and the second showed it from stale items, one selection behind
for the life of the view. updateHeader() shows it, beside the details
button it rides with.
The test missed the last one by asserting before the render landed,
measuring the placeholder; it waits on showingPlaceholder() now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 3 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 96 | ||||
| -rw-r--r-- | src/messageview.cpp | 22 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 164 |
4 files changed, 276 insertions, 9 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 e203856..63bd833 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 @@ -205,7 +205,7 @@ taking that too literally. | 133 | The composer shows no markdown syntax highlighting | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** A `QSyntaxHighlighter` over the composer's editor, so `**bold**` reads as bold while the buffer stays plain markdown. Standard Qt, no dependency. Deliberately after 123's formatting toolbar: agreeing with the grammar about nesting and about code spans suppressing what is inside them is the expensive part, and the toolbar is what makes the feature usable | | 134 | The busy indicator is built inline and is about to be built twice | maintenance | S | done, 2026-08-20, af902e0. `BusyIndicator` (`src/busyindicator.h`) carries both modes: `MainWindow` uses the indeterminate one, and item 123's send popup takes the determinate half for its undo countdown, switching the same widget over when the command starts. Only the BAR was extracted, not the status label this row paired with it. `m_statusLabel` has 34 uses across `MainWindow` for transient messages, selection counts and sync phases, so it belongs to the window rather than to the indicator, and the send popup owns its own phase text | | 135 | The formatting toolbar's buttons stack rather than toggle | v2 | S | open, 2026-08-21, asked for by the user during item 123 task 8 and reverted the same session. **A spec change, not a defect**: it conflicts with spec:236 ("deliberately no live toggle") and spec:187-190. Both sites need amending FIRST, and the amendment must resolve what replaces bold-then-italic, which is the gesture spec:187's preserved selection exists to serve and which a toggle makes unreachable. That question is the work; the state machine is understood and written up in the section | -| 136 | `undoMovesTheMessageBack` fails when run ALONE, passes in the full suite | defect | ? | open, 2026-08-21, re-measured 2026-08-24 and it is not what the row said. Filed as an intermittent race (1 in 6); it is in fact **deterministic on the selection**: 6 failures in 6 when named on the command line, and 0 failures in the full 258-test run, on a clean tree with the day's work stashed out. All three of its 15s `QTRY` timeouts expire, giving 45s against a 25s whole-suite run, so undo never moves the file rather than losing a race. A test that needs its predecessors is the likely shape (the `init()` lock-table fixture of item 61 is one candidate), which makes it a TEST defect until shown otherwise. Not caused by item 149 | +| 136 | `undoMovesTheMessageBack` fails when run ALONE, passes in the full suite | defect | ? | open, 2026-08-21, re-measured 2026-08-24 and it is not what the row said. Filed as an intermittent race (1 in 6); it is in fact **deterministic on the selection**: 6 failures in 6 when named on the command line, and, as of 2026-08-24, it fails in the FULL run too: measured at 58f13ad with the day's work stashed out, 274 passed and this one failed. The "passes in the suite" half of this row is therefore no longer true, and the selection-dependence it was named for may not be either. Re-measure before theorising. All three of its 15s `QTRY` timeouts expire, giving 45s against a 25s whole-suite run, so undo never moves the file rather than losing a race. A test that needs its predecessors is the likely shape (the `init()` lock-table fixture of item 61 is one candidate), which makes it a TEST defect until shown otherwise. Not caused by item 149 | | 137 | A reply to a message that arrived at two accounts can come from the wrong one | defect | S | open, 2026-08-22, found while building item 123 task 12. `ComposeContextBuilder::accountForReply()` takes `messagePaths` PLURAL to disambiguate, and nothing upstream ever gives it more than one path, so the disambiguation is inert | | 138 | No Drafts filter beside Sent and Trash | workflow | S | **done** 2026-08-24, unreleased. Smaller than sized: `Account::draftsQuery()` and `Config::allDraftsQuery()` already existed for the placeholder pane's count, so only the `kQueryGenerators` entry, the two `resolvedQuery` branches, the label and an icon were missing, and `builtinFilters()` derives the row from that set. Follows TRASH rather than Sent: folder-matched like both, but NOT flat, since a draft reply belongs with the conversation it answers. An account with no `drafts` key shows no button at all, per item 103's rule, which the existing row test surfaced by failing until its fixture configured one | | 139 | Forward is reachable only from the Message menu | discoverability | XS | **done** 2026-08-24, unreleased, inside 140/141 as that entry said it would be. Forward is on the message pane's own bar with Compose and Reply | @@ -226,6 +226,7 @@ taking that too literally. | 154 | No read confirmation | v2 | ? | open, 2026-08-24, from the notes. `Disposition-Notification-To`, which is a header `MessageBuilder` would add and a request the message pane would have to honour or ignore on the receiving side. Unspecified: whether this is send-side only, and what the reader is asked | | 155 | No urgency switch on an outgoing message | v2 | S | open, 2026-08-24, from the notes: low, regular, high. `X-Priority` and `Importance`, headers `MessageBuilder` adds; regular writes neither. A control in the composer, and the same question item 144 answered for the HTML toggle applies to where it sits | | 156 | No delivery confirmation | v2 | ? | open, 2026-08-24, from the notes. Distinct from 154: this is a DSN (`Return-Receipt-To`, or the ESMTP NOTIFY parameter), which is the sending server's to honour rather than the reader's client. Whether it can be requested at all depends on the `send_command`, so this may not be this application's to offer | +| 157 | A draft on display offers Reply and Forward, not Edit | workflow | XS | **done** 2026-08-24, unreleased, and the half item 153 did not close. `populateMessageBar()` swaps the reply pair for `edit_draft`, refilled from `updateComposeActions()` so it follows the message. **Took three hand-test rounds, each finding a defect the tests could not see.** First version shipped item 150's trap one level up: it keyed on `currentIndex()`, which a query leaves VALID on a row of the discarded result, so the bar kept the draft button after clicking Inbox and the reply pair after clicking Drafts. It answers from `m_currentMessageId`/`m_currentThreadId` now, which every blanking route clears, refilled from `showPlaceholderPane()` — the one site all five of those routes share. That exposed a THIRD defect nobody had reported and which predates the bar: enablement ran only from the two selection handlers, so Reply and Forward stayed **enabled over a blank pane**, invisible while they sat on the main toolbar among always-on actions. The bar is then HIDDEN over an empty pane (`!m_items.isEmpty()` in `MessageView::setBarActions`): the user first chose a greyed-out bar, then reversed it on sight for a better reason, that the subject and details button already vanish and a persisting bar was the only piece of header furniture that did not. **The hiding half broke the showing half**, found by hand again: `setBarActions` is called from `updateComposeActions()`, which runs BEFORE `showThread()` fills `m_items`, so the first message opened after any blanking left the bar hidden and the second showed it, reading `m_items` still holding the first — one selection behind for the life of the view. `updateHeader()` shows it, beside the details button it rides with. The test missed it by asserting before the render landed, measuring the placeholder; it waits on `showingPlaceholder()` now. Several guards and `hide()` calls were written across the three rounds and then measured dead, and removed | Sizes are rough: XS under an hour, S a sitting, M a session. diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index eb41da9..42fe1bf 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1298,7 +1298,17 @@ void MainWindow::updateComposeActions() // cannot send. save_message is deliberately NOT in this list: it is the // escape hatch for exactly that case, writing the raw message to a file // that can be attached to a new message from an account that can send. - const QString replyAccount = accountForCurrentMessage(); + // Everything below describes the DISPLAYED message, and every route that + // blanks the pane clears these two ids while leaving currentIndex() valid + // on a row from the previous result. Answering from the index instead + // would describe a message that is no longer on screen: it left Reply + // enabled over an empty pane, and, once this function refilled the pane's + // bar, put the wrong buttons there in both directions. + const bool showing = + !m_currentMessageId.isEmpty() || !m_currentThreadId.isEmpty(); + + const QString replyAccount = + showing ? accountForCurrentMessage() : QString(); const bool canReply = !replyAccount.isEmpty() && m_config.account(replyAccount).canSend(); @@ -1320,7 +1330,13 @@ void MainWindow::updateComposeActions() // notmuch surfaces the Maildir D flag as one, and a message flagged by // another client sits in the inbox rather than in the drafts folder. if (QAction *edit = m_actions.value(QStringLiteral("edit_draft"))) - edit->setEnabled(currentMessageIsADraft()); + edit->setEnabled(showing && currentMessageIsADraft()); + + // The pane's bar shows Edit draft in place of the reply pair on a draft + // (item 157), so it is refilled here rather than once at construction: + // this runs on every selection change, which is the only thing that can + // move a draft into or out of the pane. + populateMessageBar(); // The ribbon appears only when an account was identified AND it cannot // send. An unidentified account is not a receive-only one: it is a message @@ -2116,14 +2132,63 @@ void MainWindow::populateMessageBar() // Reply and Forward only: Compose needs no message and sits on the main // toolbar with the other window-wide actions. // + // A draft swaps that pair for Edit draft (item 157). It is the same rule + // items 139 to 141 settled, applied one level down: the bar carries what + // the DISPLAYED message affords, and a draft affords neither answering a + // sender it does not have nor passing on a message that is not finished. + // The view controls are unchanged by the swap, since how the pane renders + // is not a property of what the message is. + // + // Called from updateComposeActions() as well as at construction, so it + // follows the selection. That is also why the actions are looked up fresh + // rather than cached: the bar is refilled, never rebuilt. + // // Slightly smaller than the main toolbar's icons, deriving from the // configured size rather than hardcoding one, so the bar stays subordinate // to the chrome above it however the user sets that key. const int iconSize = qMax(16, (m_config.toolbarIconSize() * 7) / 8); + + // Gated on whether a message is DISPLAYED, not on which row is current. + // The two disagree on every route that blanks the pane without moving the + // selection: running a query leaves currentIndex() valid on a row from the + // previous result, so a bar keyed on it kept offering Edit draft over an + // empty pane after leaving the Drafts filter, and the reply pair after + // arriving at it. This is item 150's trap exactly, one level up, and the + // test that missed it moved row to row, which is the one gesture that + // cannot expose it. + // + // m_currentMessageId is cleared with the pane by every one of those + // routes, so it is the only thing that tracks what the bar describes. + // The bar always carries a message half. Whether it is SEEN is + // MessageView's question, not this one: it hides the whole bar over an + // empty pane, alongside the subject and the details button, so this only + // ever decides what a displayed message affords. + // + // Only a displayed DRAFT swaps the pair, and "displayed" is the operative + // word: keyed on m_currentMessageId rather than on currentIndex(), which + // stays valid on a row from the previous result after a query and made the + // bar describe a message that was no longer on screen, in both directions. + // That is item 150's trap one level up, and the first version of this test + // could not see it because it moved row to row, the one gesture that + // always changes both. + // currentMessageIsADraft() alone, with no displayed-message guard beside + // it: a query calls m_model->clear(), which invalidates currentIndex(), + // so the predicate is already false whenever the pane is blank. Measured, + // after writing that guard and finding no reachable state where it + // changed the answer. The guard that IS load-bearing sits one level up in + // updateComposeActions(), where accountForCurrentMessage() would otherwise + // answer about a row this query is discarding. + QList<QAction *> messageActions; + if (currentMessageIsADraft()) { + messageActions = { m_actions.value(QStringLiteral("edit_draft")) }; + } else { + messageActions = { m_actions.value(QStringLiteral("reply")), + m_actions.value(QStringLiteral("forward")) }; + } + m_messageView->setBarActions( - { m_actions.value(QStringLiteral("reply")), - m_actions.value(QStringLiteral("forward")) }, - { m_actions.value(QStringLiteral("toggle_html")) }, iconSize); + messageActions, { m_actions.value(QStringLiteral("toggle_html")) }, + iconSize); } void MainWindow::showShortcutReference() @@ -2566,6 +2631,22 @@ void MainWindow::showPlaceholderPane() { m_messageView->showPlaceholder(placeholderHelpers()); + // Every route that blanks the pane comes through here, which is why the + // bar is refilled here rather than at each of them: item 150 was the same + // defect one level down and was fixed by finding the one shared site. + // The ids this reads are cleared by the callers around the same point; + // the order between the two was measured and no test can tell it apart, + // since a query invalidates currentIndex() anyway. Left as it was found. + // + // updateComposeActions() rather than populateMessageBar() alone, because + // the ENABLEMENT was stale here too and had been since before the bar + // existed: it ran only from the two selection handlers, so a query that + // blanked the pane left Reply and Forward enabled over nothing. Invisible + // while they lived on the main toolbar among other always-on actions, and + // plain once they sat over an empty pane. It calls populateMessageBar() + // last, so the bar still gets refilled. + updateComposeActions(); + QMetaObject::invokeMethod(m_worker, "requestCounts", Qt::QueuedConnection, Q_ARG(QStringList, placeholderQueries()), Q_ARG(quint64, ++m_countsGeneration)); @@ -3171,8 +3252,6 @@ void MainWindow::runQuery(FlatResult flat, AccountScope scope) ++m_generation; m_model->clear(); - m_messageView->clear(); - showPlaceholderPane(); // Cleared WITH the pane, not merely alongside it. These three name what the // pane is showing, and both selection handlers use them to decide whether a @@ -3185,6 +3264,9 @@ void MainWindow::runQuery(FlatResult flat, AccountScope scope) // the thread is current at the moment the query replaces the view, and its // one card opens onto the placeholder. A query returning any OTHER thread // hides it, which is why it took a screenshot to find. + m_messageView->clear(); + showPlaceholderPane(); + m_currentThreadId.clear(); m_currentMessageId.clear(); m_currentMessageThreadId.clear(); diff --git a/src/messageview.cpp b/src/messageview.cpp index 4c2a9d0..86e40eb 100644 --- a/src/messageview.cpp +++ b/src/messageview.cpp @@ -617,7 +617,18 @@ void MessageView::setBarActions(const QList<QAction *> &messageActions, } } - m_messageBar->setVisible(!m_messageBar->actions().isEmpty()); + // Hidden over an empty pane whatever it holds, so it comes and goes with + // the subject and the details button rather than hovering over the logo. + // The user's call, and the reason is consistency with those two: a bar + // that persists was the only piece of header furniture that did. + // + // This guard covers the HIDING only. MainWindow refills the bar from + // showPlaceholderPane(), which every route that blanks the pane passes + // through, so the refill re-reads m_items and this line answers. Nothing + // refills it when a message ARRIVES, so updateHeader() shows it there; + // see the note beside the details button, which it rides with. + m_messageBar->setVisible(!m_messageBar->actions().isEmpty() + && !m_items.isEmpty()); } void MessageView::clear() @@ -754,6 +765,14 @@ void MessageView::updateHeader() // scratch a few lines down. m_headerOffers.clear(); + // The bar rides with the details button, but only the SHOWING half belongs + // here. Hiding is covered by setBarActions(), since MainWindow refills the + // bar on every route that blanks the pane, and a hide() in the empty + // branch below was measured to change nothing. Nothing refills the bar + // when a message ARRIVES, though, so without the show() below it stayed + // hidden for the first message opened after any blanking and appeared on + // the second, when m_items still held the first: one selection behind for + // as long as the view lasted. if (m_items.isEmpty()) { m_headerLabel->clear(); m_detailsButton->hide(); @@ -761,6 +780,7 @@ void MessageView::updateHeader() } m_detailsButton->show(); + m_messageBar->setVisible(!m_messageBar->actions().isEmpty()); // The thread's subject comes from its first message; later replies carry // Re: prefixes that add nothing. diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index a6aa7f5..c5e88db 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -488,6 +488,7 @@ private slots: void ctrlWClosesTheComposer(); void aDraftReopensWithItsOwnContent(); void editDraftIsOfferedOnlyForADraft(); + void theMessageBarOffersEditOnADraft(); void doubleClickingADraftOpensTheComposer(); void aResumedDraftReplacesItsFileRatherThanAddingOne(); void aResumedDraftKeepsItsBlindRecipients(); @@ -12595,6 +12596,169 @@ void TestMainWindow::editDraftIsOfferedOnlyForADraft() "Edit draft is not offered on a message in the drafts folder"); } +void TestMainWindow::theMessageBarOffersEditOnADraft() +{ + // Item 157, and the half item 153 did not close. A draft was editable by + // double-click and by a Message-menu entry, neither of which is where the + // user looks while reading one. The pane's own bar carries Reply and + // Forward, which are the two things a draft cannot do: it has no sender to + // answer and is not finished enough to pass on. + WorkerComposeFixture fixture; + QVERIFY(fixture.backed.fixture().addMessage( + QStringLiteral("acct/Drafts"), QStringLiteral("draft1@example.org"), + QStringLiteral("Half written"), QStringLiteral("you@example.org"), + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body."))); + QVERIFY2(fixture.seed({ { QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash"), + QStringLiteral("/bin/true"), + QStringLiteral("you@example.org"), + QStringLiteral("Drafts") } }, + QStringLiteral("acct/inbox")), + qPrintable(fixture.backed.error())); + + MainWindow window(fixture.backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + auto *view = window.findChild<ThreadListView *>(); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + // Pinned by name: since item 141 the window holds two toolbars, and an + // unnamed findChild would assert against whichever came first. + auto *bar = window.findChild<QToolBar *>(QStringLiteral("message_toolbar")); + auto *pane = window.findChild<MessageView *>(); + QVERIFY(model && view && queryEdit && bar && pane); + + const auto selectById = [&](const QString &id) { + queryEdit->setText(QStringLiteral("id:") + id); + queryEdit->returnPressed(); + bool ready = false; + for (int attempt = 0; attempt < 150 && !ready; ++attempt) { + ready = model->rowCount(QModelIndex()) == 1 + && !window.mailRootForTesting().isEmpty(); + if (!ready) + QTest::qWait(100); + } + if (!ready) + return false; + view->setCurrentIndex(model->index(0, 0, QModelIndex())); + return true; + }; + + const auto barHolds = [&](const QString &name) { + const auto actions = bar->actions(); + return std::any_of(actions.cbegin(), actions.cend(), + [&](const QAction *action) { + return action && action->objectName() == name; + }); + }; + + // Ordinary mail first, so the assertions below mean something: a bar that + // never holds Edit passes the draft check by accident if the reply pair is + // simply absent everywhere. + QVERIFY2(selectById(QStringLiteral("compose1@example.org")), + "the inbox message was not found"); + QVERIFY2(barHolds(QStringLiteral("reply")), + "the message bar lost Reply on ordinary mail"); + QVERIFY2(barHolds(QStringLiteral("forward")), + "the message bar lost Forward on ordinary mail"); + QVERIFY2(!barHolds(QStringLiteral("edit_draft")), + "Edit draft is on the message bar for a message in the inbox"); + + // toggle_html is the view-control half, on the far side of the stretch. It + // describes how the pane renders and not what the message is, so it must + // survive the swap: a draft can be read as HTML like anything else. + QVERIFY2(barHolds(QStringLiteral("toggle_html")), + "the view controls were lost from the message bar"); + + QVERIFY2(selectById(QStringLiteral("draft1@example.org")), + "the draft was not found"); + QVERIFY2(barHolds(QStringLiteral("edit_draft")), + "Edit draft is missing from the message bar on a draft"); + QVERIFY2(!barHolds(QStringLiteral("reply")), + "Reply is still offered on a draft, which has nobody to answer"); + QVERIFY2(!barHolds(QStringLiteral("forward")), + "Forward is still offered on a draft"); + QVERIFY2(barHolds(QStringLiteral("toggle_html")), + "the view controls were lost when the bar swapped to a draft"); + + // And back, because a one-way swap is the plausible defect: the bar is + // refilled on every selection change, so returning to ordinary mail has to + // restore the pair rather than leaving Edit behind on mail it must not act + // on. + QVERIFY2(selectById(QStringLiteral("compose1@example.org")), + "the inbox message was not found on the way back"); + QVERIFY2(barHolds(QStringLiteral("reply")), + "Reply did not come back after leaving a draft"); + QVERIFY2(!barHolds(QStringLiteral("edit_draft")), + "Edit draft stayed on the bar after leaving the draft"); + + // And the gesture the first version of this test could not see. Running a + // query blanks the pane WITHOUT moving the selection, so currentIndex() + // stays valid on a row from the previous result and a bar keyed on it + // describes a message that is no longer displayed. Both directions were + // reported: Edit draft left over an empty pane after leaving Drafts, and + // the reply pair left over one after arriving. + const auto runQuery = [&](const QString &query) { + queryEdit->setText(query); + queryEdit->returnPressed(); + QTest::qWait(300); + }; + + QVERIFY2(selectById(QStringLiteral("draft1@example.org")), + "the draft was not found before the query change"); + QVERIFY2(barHolds(QStringLiteral("edit_draft")), "precondition: on a draft"); + + // A query with RESULTS, which is what the user reported and what a query + // matching nothing cannot reproduce: with no rows the selection goes + // invalid and the stale-index answer is accidentally right. Here the list + // repopulates, currentIndex() lands on a row of the NEW result, and the + // pane is still blank because nothing has been selected by hand. + runQuery(QStringLiteral("tag:inbox")); + QVERIFY2(model->rowCount(QModelIndex()) > 0, + "the blanking query returned nothing, which is the case that " + "cannot reproduce the defect"); + + // The whole bar goes with the pane, which is the shape the user settled on + // after looking at the greyed-out one: the subject and the details button + // already vanish when the pane is cleared, and a persisting action bar was + // the only piece of header furniture that did not. + QVERIFY2(bar->isHidden(), + "the message bar is still shown over a blank pane"); + + // Enablement is a separate property from visibility and was ALSO stale: + // updateComposeActions() ran only from the selection handlers, so a query + // that blanked the pane left Reply enabled. A hidden bar would hide that, + // but the Message menu shows the same QAction. + auto *reply = window.findChild<QAction *>(QStringLiteral("reply")); + QVERIFY(reply); + QVERIFY2(!reply->isEnabled(), "Reply is enabled over a blank pane"); + + // The other direction, and the one the user reported second: arriving at + // Drafts from the inbox left the reply pair over the blank pane. Selecting + // a draft after the query must reach Edit draft, which it cannot if the + // bar is only refilled on a selection change. + QVERIFY2(selectById(QStringLiteral("draft1@example.org")), + "the draft was not found after the blanking query"); + + // The FIRST message opened after a blanking, which is the gesture the + // hiding half broke: nothing refills the bar when a message arrives, so a + // bar hidden by the query stayed hidden until a SECOND selection, where + // m_items still held the first message and the guard passed one behind. + // Asserted before the membership checks below, since a bar that is filled + // correctly and invisible passes every one of them. + // The pane loads through the worker, so the render lands a turn or more + // after the selection. Waiting on the pane itself rather than on a fixed + // delay, which passes when the render never arrives. + QTRY_VERIFY_WITH_TIMEOUT(!pane->showingPlaceholder(), 15000); + QVERIFY2(!bar->isHidden(), + "the message bar stayed hidden for the first message opened " + "after the pane was blanked"); + QVERIFY2(barHolds(QStringLiteral("edit_draft")), + "Edit draft did not return after the pane was blanked"); + QVERIFY2(!barHolds(QStringLiteral("reply")), + "Reply is offered on a draft reached through a blank pane"); +} + void TestMainWindow::aDraftReopensWithItsOwnContent() { // Item 153. A draft was write-only: DraftStore had a write() and no |
