aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-26 13:28:51 +0200
committerDanilo M. <danix@danix.xyz>2026-08-26 13:30:22 +0200
commit0ca4624195cdd8c78ff614e3912af5b914458497 (patch)
tree6fc739e77fed6b5a4e178cacf76ac29fd708ab0b /tests
parentd835cf3c554e9657fffdb91971b66e3a74aee323 (diff)
downloadqtmaildir-0ca4624195cdd8c78ff614e3912af5b914458497.tar.gz
qtmaildir-0ca4624195cdd8c78ff614e3912af5b914458497.zip
feat: flag what you answered, mark what was forwarded to you
Item 68, which turned out to be three things once its premise was measured. The note asked to extend a "passed" subject rule to "Fw:"; there was no subject rule, and the correlation it rested on did not exist. What did exist was a gap nobody had reported. Reply and forward now flag their source. The Maildir R and P flags, which every other client sets and notmuch reads back as "replied" and "passed", had never been written here: measured on the developer's index, all 317 "replied" and all 6 "passed" came from other clients. ComposeWindow emits sourceMessageAnswered after a successful send and MainWindow routes it through sendMessageTagChange, message-scoped and off the undo stack, for the reason auto mark-read is: the flag records that the mail went, and the send cannot be undone. ComposeContext carries sourceMessageId rather than reusing inReplyTo, which is deliberately empty on a forward so the recipient's client does not file it under the thread it left. Keying on it made the "passed" half dead code that compiled and never fired. A resumed draft is excluded: its kind records how the file was opened, not what the user is doing, so flagging on it would set R from a guess. A received forward gets its own mark. Derived from the subject at paint time, storing nothing and reaching no server, because "passed" means "I forwarded this" and setting it from a guess would assert something false on 222 existing messages. subjectIsForwarded() shares forwardSubject()'s prefix table so the two cannot disagree, strips a Re: chain first, and takes extra locale spellings from [general] forward_prefixes, which extends the built-in table rather than replacing it. A mutation survived the first round and corrected a claim in the code: QRegularExpression::escape already makes a punctuation prefix inert, so the word guard is not about pattern validity. It stops a configured "-" matching "-: x". The comment and test say that now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
Diffstat (limited to 'tests')
-rw-r--r--tests/test_composecontext.cpp77
-rw-r--r--tests/test_mainwindow.cpp306
-rw-r--r--tests/test_threadlistmodel.cpp97
3 files changed, 474 insertions, 6 deletions
diff --git a/tests/test_composecontext.cpp b/tests/test_composecontext.cpp
index fccec87..bea390d 100644
--- a/tests/test_composecontext.cpp
+++ b/tests/test_composecontext.cpp
@@ -81,6 +81,8 @@ private slots:
void aSingleLetterBeforeAColonIsNotAPrefix();
// Account resolution.
+ void aReceivedForwardIsRecognisedFromItsSubject();
+ void configuredForwardPrefixesExtendTheBuiltInTable();
void theReplyAccountComesFromTheMessagesMaildir();
void anAccountIsNotMatchedByAPrefixOfItsMaildir();
void anAmbiguousMessagePrefersTheMatchingRecipient();
@@ -808,6 +810,81 @@ void TestComposeContext::aSingleLetterBeforeAColonIsNotAPrefix()
QStringLiteral("Fwd: F: results"));
}
+void TestComposeContext::aReceivedForwardIsRecognisedFromItsSubject()
+{
+ using ComposeContextBuilder::subjectIsForwarded;
+
+ // Item 68. The display predicate behind the received-forward mark. It
+ // shares forwardSubject()'s prefix table deliberately, so the two cannot
+ // disagree about what a forward looks like.
+ QVERIFY(subjectIsForwarded(QStringLiteral("Fwd: budget")));
+ QVERIFY(subjectIsForwarded(QStringLiteral("Fw: budget")));
+ QVERIFY(subjectIsForwarded(QStringLiteral("FWD: budget")));
+ QVERIFY(subjectIsForwarded(QStringLiteral("WG: Angebot")));
+ QVERIFY(subjectIsForwarded(QStringLiteral("TR: document")));
+
+ // Anchored. "Fwd:" inside a subject is a quotation, not a marker, and the
+ // whole reason item 68's entry insisted on anchoring.
+ QVERIFY(!subjectIsForwarded(QStringLiteral("Notes fwd: budget")));
+ QVERIFY(!subjectIsForwarded(QStringLiteral("budget")));
+ QVERIFY(!subjectIsForwarded(QString()));
+
+ // The single-letter spellings stay unrecognised here for exactly the
+ // reason forwardSubject() rejects them: "I: notes" is an ordinary subject.
+ QVERIFY(!subjectIsForwarded(QStringLiteral("I: notes")));
+ QVERIFY(!subjectIsForwarded(QStringLiteral("F: results")));
+
+ // A reply to a forward is still a forward the user received, so the Re:
+ // chain is stripped first. Both orders, and a counted Outlook form.
+ QVERIFY(subjectIsForwarded(QStringLiteral("Re: Fwd: budget")));
+ QVERIFY(subjectIsForwarded(QStringLiteral("Re: Re: Fwd: budget")));
+ QVERIFY(subjectIsForwarded(QStringLiteral("Re[2]: Fwd: budget")));
+ QVERIFY(subjectIsForwarded(QStringLiteral("AW: WG: Angebot")));
+
+ // A plain reply is not a forward, however deep the chain.
+ QVERIFY(!subjectIsForwarded(QStringLiteral("Re: budget")));
+ QVERIFY(!subjectIsForwarded(QStringLiteral("Re: Re: Re: budget")));
+}
+
+void TestComposeContext::configuredForwardPrefixesExtendTheBuiltInTable()
+{
+ using ComposeContextBuilder::subjectIsForwarded;
+
+ // Item 68. [general] forward_prefixes ADDS to the table rather than
+ // replacing it: a user adding Dutch must not lose English.
+ const QStringList dutch = { QStringLiteral("Doorst") };
+ QVERIFY(subjectIsForwarded(QStringLiteral("Doorst: begroting"), dutch));
+ QVERIFY(subjectIsForwarded(QStringLiteral("Fwd: budget"), dutch));
+
+ // Case-insensitive and counted forms, like the built-ins.
+ QVERIFY(subjectIsForwarded(QStringLiteral("DOORST: begroting"), dutch));
+ QVERIFY(subjectIsForwarded(QStringLiteral("Doorst[2]: begroting"), dutch));
+ QVERIFY(subjectIsForwarded(QStringLiteral("Re: Doorst: begroting"), dutch));
+
+ // An unconfigured spelling stays unrecognised, which is what makes the
+ // key worth having rather than the predicate matching anything.
+ QVERIFY(!subjectIsForwarded(QStringLiteral("Doorst: begroting")));
+
+ // Non-word entries are ignored per entry. Measured 2026-08-26: escaping
+ // alone already makes punctuation inert, so what the guard actually buys
+ // is that a configured "-" does not make "-: x" a forward, and a digit
+ // does not make "2: x" one. Neither is a marker any client emits.
+ QVERIFY(!subjectIsForwarded(QStringLiteral("-: x"),
+ { QStringLiteral("-") }));
+ QVERIFY(!subjectIsForwarded(QStringLiteral("2: x"),
+ { QStringLiteral("2") }));
+
+ // An empty or blank entry contributes nothing rather than matching
+ // everything, which is the failure that would be silent and total.
+ const QStringList blank = { QString(), QStringLiteral(" ") };
+ QVERIFY(!subjectIsForwarded(QStringLiteral("budget"), blank));
+ QVERIFY(!subjectIsForwarded(QStringLiteral("anything at all"), blank));
+
+ // A configured "Re" must not turn every reply into a forward.
+ QVERIFY(!subjectIsForwarded(QStringLiteral("Re: budget"),
+ { QStringLiteral("Re") }));
+}
+
// ---------------------------------------------------------------------------
// Account resolution
// ---------------------------------------------------------------------------
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index f76ff70..2032793 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -124,6 +124,10 @@ public:
/// Written only when non-empty, like trash: an account without one
/// offers no Drafts filter and no Edit draft (items 138 and 153).
QString drafts;
+ /// Where a sent copy is filed. Written only when non-empty; an
+ /// account without one sends and files nothing, which is a real
+ /// configuration rather than an error.
+ QString sent;
};
/// Writes several accounts, for the compose cases.
@@ -202,6 +206,8 @@ public:
out << "send_command=" << account.sendCommand << "\n";
if (!account.drafts.isEmpty())
out << "drafts=" << account.drafts << "\n";
+ if (!account.sent.isEmpty())
+ out << "sent=" << account.sent << "\n";
}
}
file.close();
@@ -472,7 +478,7 @@ private slots:
void twoDeletesToOneTrashBothGetTheirTags();
void deletingTwiceLeavesNoOriginTagBehind();
void undoOfADeleteRemovesTheOriginTagToo();
- void deletingAThreadRootTwiceRestoresItRatherThanRedeleting();
+ void deletingAThreadRootRemovesItFromTheInboxAndUndoReturnsIt();
void deleteThreadMovesEveryMessageAndRepaintsTheRootCard();
void aFolderNameWithASpaceSurvivesTheRoundTrip();
void deleteIsBoundToTheDeleteKey();
@@ -529,6 +535,11 @@ private slots:
void anUnchangedMessageIsNotWrittenAgain();
void closingInsideTheDebounceStillSavesTheDraft();
void closingAfterASendWritesNoFurtherDraft();
+ void aSendRemovesADraftMbsyncHasRenamed();
+ void aForwardFlagsTheMessageItForwarded();
+ void aReplyFlagsTheMessageItAnswered();
+ void aResumedDraftFlagsNothing();
+ void aForwardWritesThePassedTagToTheIndex();
void aCloseDuringTheCountdownIsRefused();
void aFailedSendKeepsTheTextThatFailedToGo();
void aSmallSizeLimitIsNotDescribedAsZeroMegabytes();
@@ -11286,7 +11297,7 @@ void TestMainWindow::undoOfADeleteRemovesTheOriginTagToo()
0);
}
-void TestMainWindow::deletingAThreadRootTwiceRestoresItRatherThanRedeleting()
+void TestMainWindow::deletingAThreadRootRemovesItFromTheInboxAndUndoReturnsIt()
{
// The toggle asked a THREAD ROW about its thread's tags, which notmuch
// gives as a UNION over the conversation. Delete the root of a
@@ -11367,9 +11378,20 @@ void TestMainWindow::deletingAThreadRootTwiceRestoresItRatherThanRedeleting()
"tag:deleted")),
0);
- // Second press on the row as it stands, no re-query.
- view->setCurrentIndex(model->index(0, 0, QModelIndex()));
- window.findChild<QAction *>(QStringLiteral("delete"))->trigger();
+ // There is no second press to make any more, and that is the point.
+ //
+ // Item 16's double-press-to-undelete existed because the deleted row
+ // STAYED in the view with nothing else to act on. Since 2026-08-26 Delete
+ // strips `inbox` too, so in this `tag:inbox` view the row LEAVES: the
+ // mitigation is unreachable here because the thing it mitigated is gone.
+ // Confirmed with the user, who chose this over keeping the toggle.
+ //
+ // Undo is what retracts now, and it must put back BOTH halves: the file
+ // and the tag travelled in one TagChange precisely so one Ctrl+Z returns
+ // them together.
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 0, 15000);
+
+ window.findChild<QAction *>(QStringLiteral("undo"))->trigger();
QTRY_VERIFY_WITH_TIMEOUT(
folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), stem)
@@ -11382,6 +11404,16 @@ void TestMainWindow::deletingAThreadRootTwiceRestoresItRatherThanRedeleting()
== 0,
15000);
+ // The tag half of the same undo. Asserted separately because the file
+ // moving back and `inbox` coming back are two different failures, and a
+ // restore that returns the file without the tag is invisible in the view
+ // it was returned to.
+ QTRY_VERIFY_WITH_TIMEOUT(
+ notmuchCount(cfg,
+ QStringLiteral("id:troot@example.org and tag:inbox"))
+ == 1,
+ 15000);
+
// Asked of notmuch directly: a UI query reads 0 rows for the whole
// interval before the worker answers, so an absence assertion through the
// query bar passes against any state of the database.
@@ -12171,9 +12203,17 @@ void TestMainWindow::twoDeletesToOneTrashBothGetTheirTags()
// Both Deletes issued back to back, WITHOUT waiting for the first to be
// confirmed. That is the whole point: waiting would serialise them and
// the keyed table would have coped.
+ //
+ // Both take row 0, and that is not a typo. Delete strips `inbox`, and in
+ // this `tag:inbox` view the row it stripped it from LEAVES the list
+ // immediately, so what was row 1 becomes row 0 the moment the first
+ // Delete is triggered. Naming index(1, 0) here would select a row that no
+ // longer exists and the second message would never be deleted at all,
+ // which is exactly how this test failed when the removal was added.
view->setCurrentIndex(model->index(0, 0, QModelIndex()));
window.findChild<QAction *>(QStringLiteral("delete"))->trigger();
- view->setCurrentIndex(model->index(1, 0, QModelIndex()));
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
window.findChild<QAction *>(QStringLiteral("delete"))->trigger();
const QString root = backed.fixture().maildirPath();
@@ -14443,6 +14483,260 @@ void TestMainWindow::closingAfterASendWritesNoFurtherDraft()
QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
}
+void TestMainWindow::aSendRemovesADraftMbsyncHasRenamed()
+{
+ // Measured on the user's own mail, 2026-08-26: a forward was sent, the
+ // recipient got it, the sent copy was filed, and the draft STAYED in the
+ // Drafts view carrying the `D` flag.
+ //
+ // mbsync renames an uploaded draft to add its `,U=<uid>` infix while
+ // m_draftPath still holds the name DraftStore::write() returned, so
+ // QFile::remove() ran against a path that no longer existed and failed
+ // silently. Item 163 added MaildirName::resolveRenamed() for exactly this
+ // rename and wired it into the three READ sites; this is the WRITE site,
+ // and it was missed.
+ //
+ // closingAfterASendWritesNoFurtherDraft() already asserts the drafts
+ // folder is empty after a send and passed throughout, because its draft is
+ // never renamed. The rename is the whole defect, so it has to be in the
+ // fixture.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction =
+ window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ body->setPlainText(QStringLiteral("Text that is about to be sent."));
+ QVERIFY(window->saveDraftNow());
+ QCOMPARE(fixture.draftCount(), 1);
+
+ // Renamed exactly as mbsync renames it: the `,U=<uid>` infix goes before
+ // the `:2,` flag separator, so the stem the composer remembers is still a
+ // prefix of the real name and nothing but a directory scan can find it.
+ const QStringList before =
+ QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList();
+ QCOMPARE(before.size(), 1);
+ const QString original = before.first();
+ const int sep = original.indexOf(QStringLiteral(":2,"));
+ QVERIFY2(sep > 0, "the draft filename carries no :2, flag separator");
+ const QString renamed = original.left(sep) + QStringLiteral(",U=7")
+ + original.mid(sep);
+ QVERIFY(QFile::rename(fixture.draftsCur() + QLatin1Char('/') + original,
+ fixture.draftsCur() + QLatin1Char('/') + renamed));
+ QCOMPARE(fixture.draftCount(), 1);
+
+ sendAction->trigger();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // The sent copy proves the send actually completed, so an empty drafts
+ // folder below means the removal worked rather than that nothing ran.
+ const QString sentCur =
+ fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
+
+ QCOMPARE(fixture.draftCount(), 0);
+}
+
+void TestMainWindow::aForwardFlagsTheMessageItForwarded()
+{
+ // Item 68. The signal that carries the P flag back to the source message.
+ // Asserted on the SIGNAL rather than on the tag, because the tag write is
+ // MainWindow's and needs a worker; what can go wrong here is the composer
+ // never emitting, which is what the user observed on 2026-08-26.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Forward;
+ context.to = { QStringLiteral("someone@example.org") };
+
+ // Set for a forward as well as a reply, and deliberately NOT inReplyTo:
+ // that header is empty on a forward, so keying the emit on it made this
+ // half dead code that compiled and never fired.
+ context.sourceMessageId = QStringLiteral("original@example.org");
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction =
+ window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ QString flaggedId;
+ QString flaggedTag;
+ connect(window.data(), &ComposeWindow::sourceMessageAnswered,
+ [&](const QString &id, const QString &tag) {
+ flaggedId = id;
+ flaggedTag = tag;
+ });
+
+ body->setPlainText(QStringLiteral("Passing this on."));
+ sendAction->trigger();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ // The sent copy proves the send completed, so an unset tag below is a
+ // missing emit rather than a send that never happened.
+ const QString sentCur =
+ fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
+
+ QCOMPARE(flaggedId, QStringLiteral("original@example.org"));
+ QCOMPARE(flaggedTag, QStringLiteral("passed"));
+}
+
+void TestMainWindow::aReplyFlagsTheMessageItAnswered()
+{
+ // The other half of item 68, and the one the measurement said was ALSO
+ // missing: all 317 `replied` in the developer's index came from other
+ // clients, because nothing here had ever written the R flag.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Reply;
+ context.to = { QStringLiteral("someone@example.org") };
+ context.sourceMessageId = QStringLiteral("original@example.org");
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction =
+ window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ QString flaggedTag;
+ connect(window.data(), &ComposeWindow::sourceMessageAnswered,
+ [&](const QString &, const QString &tag) { flaggedTag = tag; });
+
+ body->setPlainText(QStringLiteral("Answering."));
+ sendAction->trigger();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ QCOMPARE(flaggedTag, QStringLiteral("replied"));
+}
+
+void TestMainWindow::aResumedDraftFlagsNothing()
+{
+ // Kind::Draft records how the FILE was opened, not what the user is
+ // doing, so a draft that began as a reply cannot be told from one that
+ // began as a new message. Flagging on it would set R from a guess, and
+ // maildir.synchronize_flags carries a wrong flag to the server.
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.kind = ComposeContext::Kind::Draft;
+ context.to = { QStringLiteral("someone@example.org") };
+
+ // Present, and must still be ignored: this is the case a guard keyed only
+ // on the id being non-empty would get wrong.
+ context.sourceMessageId = QStringLiteral("original@example.org");
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction =
+ window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ bool emitted = false;
+ connect(window.data(), &ComposeWindow::sourceMessageAnswered,
+ [&](const QString &, const QString &) { emitted = true; });
+
+ body->setPlainText(QStringLiteral("Finishing this off."));
+ sendAction->trigger();
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ const QString sentCur =
+ fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u);
+
+ QVERIFY2(!emitted, "a resumed draft flagged a message it cannot know it "
+ "was answering");
+}
+
+void TestMainWindow::aForwardWritesThePassedTagToTheIndex()
+{
+ // The END-TO-END half: aForwardFlagsTheMessageItForwarded() proves the
+ // composer emits, and this proves the tag actually reaches notmuch. The
+ // user forwarded real mail on 2026-08-26, the recipient got it, the sent
+ // copy was filed, and `tag:passed` never moved, so the gap is somewhere
+ // between the emit and the index and only a real worker can show which.
+ WorkerComposeFixture fixture;
+ QVERIFY2(fixture.seed({ { QStringLiteral("acct"), QStringLiteral("acct"),
+ QStringLiteral("Trash"),
+ QStringLiteral("/bin/true"),
+ QStringLiteral("you@example.org"),
+ QStringLiteral("Drafts"),
+ QStringLiteral("Sent") } },
+ QStringLiteral("acct/inbox")),
+ qPrintable(fixture.backed.error()));
+
+ MainWindow window(fixture.backed.config());
+ QVERIFY(WorkerComposeFixture::selectTheMessage(window));
+
+ auto *forward = window.findChild<QAction *>(QStringLiteral("forward"));
+ QVERIFY(forward);
+
+ // Forward is gated on a message being DISPLAYED, not merely selected:
+ // updateComposeActions() enables it from the pane. A disabled action's
+ // trigger() is a silent no-op, so asserting this is what stops the test
+ // measuring nothing.
+ QTRY_VERIFY_WITH_TIMEOUT(forward->isEnabled(), 15000);
+ forward->trigger();
+
+ // Forward is ASYNCHRONOUS: composeReply() asks the worker to load the
+ // message and the composer opens when that reply lands. Calling
+ // openComposerForTest() straight after the trigger returns before the
+ // round trip finishes, and the first version of this test did exactly
+ // that, then asserted on a composer whose kind was New and whose
+ // sourceMessageId was empty. Waiting on the COUNT is what makes the
+ // composer under test the one Forward opened.
+ QTRY_VERIFY_WITH_TIMEOUT(window.openComposerCount() == 1, 15000);
+
+ // openComposersForTest(), NOT openComposerForTest(): the singular one
+ // OPENS a fresh Kind::New composer rather than returning an existing one,
+ // which is what the Compose action's tests want and is a trap here. The
+ // first version of this test used it, sent from the composer it had just
+ // created, and reported kind=0 with an empty sourceMessageId, reading
+ // exactly like the product defect it was written to reproduce.
+ const QList<ComposeWindow *> composers = window.openComposersForTest();
+ QCOMPARE(composers.size(), 1);
+ ComposeWindow *composer = composers.first();
+ QVERIFY2(composer, "Forward opened no composer");
+
+ auto *body = composer->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ auto *sendAction =
+ composer->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(body && sendAction);
+
+ auto *to = composer->findChild<QLineEdit *>(QStringLiteral("to"));
+ QVERIFY(to);
+ to->setText(QStringLiteral("someone@example.org"));
+ body->setPlainText(QStringLiteral("Passing this on."));
+
+ sendAction->trigger();
+
+ // The tag lands through the worker, so this waits on the DATABASE rather
+ // than on a signal: the whole question is whether the write arrives.
+ const QString cfg = fixture.backed.fixture().configPath();
+ QTRY_VERIFY_WITH_TIMEOUT(
+ notmuchCount(cfg, QStringLiteral("id:compose1@example.org and "
+ "tag:passed")) == 1,
+ 15000);
+}
+
void TestMainWindow::aCloseDuringTheCountdownIsRefused()
{
ComposeFixture fixture;
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index 811b3e3..593a777 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -27,6 +27,9 @@ class TestThreadListModel : public QObject
{
Q_OBJECT
private slots:
+ void aRowLeavesTheViewWhenItLosesTheViewsTag();
+ void rowsLosingTheTagAreRemovedInOneContiguousRun();
+ void theTrashViewDrawsNoDoomedFill();
void messageNodeHoldsDisplayFacts();
void rootRowsSurviveTheTreeConversion();
void repliesBecomeChildRowsUnderTheirThread();
@@ -2165,5 +2168,99 @@ void TestThreadListModel::recipientsReplaceTheSenderWhenPresent()
QStringLiteral("You"));
}
+void TestThreadListModel::aRowLeavesTheViewWhenItLosesTheViewsTag()
+{
+ ThreadListModel model;
+ ThreadSummary a = makeThread(QStringLiteral("t1"), QStringLiteral("Keep"));
+ a.firstMessageId = QStringLiteral("m1");
+ ThreadSummary b = makeThread(QStringLiteral("t2"), QStringLiteral("Drop"));
+ b.firstMessageId = QStringLiteral("m2");
+ ThreadSummary c = makeThread(QStringLiteral("t3"), QStringLiteral("Keep2"));
+ c.firstMessageId = QStringLiteral("m3");
+ model.appendBatch({ a, b, c });
+ QCOMPARE(model.rowCount(), 3);
+
+ // The middle row loses `inbox`, as Delete strips it. Middle deliberately:
+ // a removal at either end can be right by accident while the index
+ // arithmetic is wrong.
+ model.applyMessageTagChange(QStringLiteral("m2"), {},
+ { QStringLiteral("inbox") });
+ model.removeThreadsWithoutTag(QStringLiteral("inbox"));
+
+ QCOMPARE(model.rowCount(), 2);
+ QCOMPARE(model.index(0, 0, QModelIndex())
+ .data(ThreadListModel::SubjectRole).toString(),
+ QStringLiteral("Keep"));
+ QCOMPARE(model.index(1, 0, QModelIndex())
+ .data(ThreadListModel::SubjectRole).toString(),
+ QStringLiteral("Keep2"));
+}
+
+void TestThreadListModel::rowsLosingTheTagAreRemovedInOneContiguousRun()
+{
+ ThreadListModel model;
+ QList<ThreadSummary> batch;
+ for (int i = 1; i <= 5; ++i) {
+ ThreadSummary t = makeThread(QStringLiteral("t%1").arg(i),
+ QStringLiteral("S%1").arg(i));
+ t.firstMessageId = QStringLiteral("m%1").arg(i);
+ batch.append(t);
+ }
+ model.appendBatch(batch);
+
+ // Three adjacent rows go at once, which is the case a backwards walk in
+ // runs handles and a naive forward loop gets wrong by renumbering.
+ for (const QString &id : { QStringLiteral("m2"), QStringLiteral("m3"),
+ QStringLiteral("m4") }) {
+ model.applyMessageTagChange(id, {}, { QStringLiteral("inbox") });
+ }
+ model.removeThreadsWithoutTag(QStringLiteral("inbox"));
+
+ QCOMPARE(model.rowCount(), 2);
+ QCOMPARE(model.index(0, 0, QModelIndex())
+ .data(ThreadListModel::SubjectRole).toString(),
+ QStringLiteral("S1"));
+ QCOMPARE(model.index(1, 0, QModelIndex())
+ .data(ThreadListModel::SubjectRole).toString(),
+ QStringLiteral("S5"));
+}
+
+void TestThreadListModel::theTrashViewDrawsNoDoomedFill()
+{
+ ThreadListModel model;
+ ThreadSummary deleted = makeThread(QStringLiteral("t1"),
+ QStringLiteral("Thrown away"));
+ deleted.tags = QStringList{ QStringLiteral("deleted") };
+ ThreadSummary spam = makeThread(QStringLiteral("t2"),
+ QStringLiteral("Junk"));
+ spam.tags = QStringList{ QStringLiteral("deleted"), QStringLiteral("spam") };
+ model.appendBatch({ deleted, spam });
+
+ const QModelIndex first = model.index(0, 0, QModelIndex());
+ const QModelIndex second = model.index(1, 0, QModelIndex());
+
+ // Outside the trash both are filled, which is the guard proving the
+ // assertion below can fail.
+ QVERIFY(first.data(Qt::BackgroundRole).isValid());
+ QVERIFY(second.data(Qt::BackgroundRole).isValid());
+
+ model.setTrashView(true);
+
+ // A plainly deleted row loses the fill AND the white text that only reads
+ // against it; the strike-out is what still says deleted, and is asserted
+ // by the font role rather than by colour.
+ QVERIFY(!first.data(Qt::BackgroundRole).isValid());
+ QVERIFY(first.data(Qt::FontRole).value<QFont>().strikeOut());
+
+ // A spam row keeps its tint: the trash promises "thrown away", not
+ // "harmless".
+ QVERIFY(second.data(Qt::BackgroundRole).isValid());
+
+ // And the flag does not stick: leaving the trash restores the fill, which
+ // is the leak the setter's comment warns about.
+ model.setTrashView(false);
+ QVERIFY(first.data(Qt::BackgroundRole).isValid());
+}
+
QTEST_MAIN(TestThreadListModel)
#include "test_threadlistmodel.moc"