diff options
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 76 | ||||
| -rw-r--r-- | src/mainwindow.h | 18 | ||||
| -rw-r--r-- | src/pendingchangesdialog.cpp | 121 | ||||
| -rw-r--r-- | src/pendingchangesdialog.h | 86 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 48 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 35 | ||||
| -rw-r--r-- | tests/test_pendingchangesdialog.cpp | 149 | ||||
| -rw-r--r-- | translations/qtmaildir_it_IT.ts | 26 |
10 files changed, 561 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7cec9b3..591e95f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -35,6 +35,7 @@ add_library(qtmaildir_lib STATIC threadcidmap.cpp messageview.cpp messagedetailsdialog.cpp + pendingchangesdialog.cpp mainwindow.cpp querycompleter.cpp rulequery.cpp diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 0c6092e..231a9a5 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -26,6 +26,7 @@ #include <QApplication> #include <QCloseEvent> #include <QKeyEvent> +#include <QMouseEvent> #include <QComboBox> #include <QDialog> #include <QDialogButtonBox> @@ -65,6 +66,7 @@ #include "searchterm.h" #include "tagchip.h" #include "tagdialog.h" +#include "pendingchangesdialog.h" #include "savequerydialog.h" #include "tagrulesdialog.h" #include "threadlistmodel.h" @@ -490,6 +492,20 @@ bool MainWindow::eventFilter(QObject *watched, QEvent *event) // cannot fail. } + // The unsynced-changes indicator opens its list on a click (item 119). A + // QLabel has no clicked signal, so the press is taken here rather than + // replacing the label with a flat QToolButton: a button would inherit the + // style's button metrics inside a status bar, and the label already sits + // correctly. + if (watched == m_pendingLabel + && event->type() == QEvent::MouseButtonRelease) { + auto *mouse = static_cast<QMouseEvent *>(event); + if (mouse->button() == Qt::LeftButton) { + showPendingChanges(); + return true; + } + } + return QMainWindow::eventFilter(watched, event); } @@ -682,6 +698,11 @@ void MainWindow::buildUi() // them together. m_pendingLabel = new QLabel(this); m_pendingLabel->setObjectName(QStringLiteral("pendingEdits")); + // Clickable, opening the list of what it counts (item 119). The cursor is + // the only affordance a status-bar label can carry, so it is what says + // this one can be opened. + m_pendingLabel->setCursor(Qt::PointingHandCursor); + m_pendingLabel->installEventFilter(this); m_pendingLabel->hide(); statusBar()->addPermanentWidget(m_pendingLabel); @@ -2618,6 +2639,8 @@ void MainWindow::wireWorker() // unrelated error would roll back a change that actually succeeded. connect(m_worker, &NotmuchWorker::tagsApplied, this, &MainWindow::onTagsApplied); + connect(m_worker, &NotmuchWorker::pendingSubjectsResolved, + this, &MainWindow::onPendingSubjectsResolved); // messagesMovedFrom rather than messagesMoved: the tags a move carries can // only be resolved once the origins are known, and that signal is the one @@ -5108,6 +5131,59 @@ QVector<PendingChange> MainWindow::pendingChangeSnapshot() const return rows; } +void MainWindow::showPendingChanges() +{ + // The snapshot is taken HERE, at the click, and is what the dialog shows + // however long it stays open. Nothing refreshes it: the count the user + // clicked is the list they get. + m_pendingChangeRequest = pendingChangeSnapshot(); + + if (m_pendingChangeRequest.isEmpty() || !m_worker) { + // Nothing to resolve. Shown anyway rather than silently ignoring the + // click, since a window saying "nothing is waiting" is an answer and a + // dead click is not. + PendingChangesDialog(m_pendingChangeRequest, this).exec(); + m_pendingChangeRequest.clear(); + return; + } + + QStringList ids; + QList<bool> areThreads; + ids.reserve(m_pendingChangeRequest.size()); + areThreads.reserve(m_pendingChangeRequest.size()); + for (const PendingChange &change : m_pendingChangeRequest) { + ids.append(change.id); + areThreads.append(change.isThread); + } + + QMetaObject::invokeMethod(m_worker, "resolvePendingSubjects", + Qt::QueuedConnection, + Q_ARG(QStringList, ids), + Q_ARG(QList<bool>, areThreads)); +} + +void MainWindow::onPendingSubjectsResolved(const QStringList &subjects, + const QList<int> &messageCounts) +{ + // Positional, so the two must line up. A mismatch means the answer is not + // this request's, which is not something to render half of. + if (m_pendingChangeRequest.isEmpty() + || subjects.size() != m_pendingChangeRequest.size() + || messageCounts.size() != m_pendingChangeRequest.size()) { + m_pendingChangeRequest.clear(); + return; + } + + QVector<PendingChange> changes = m_pendingChangeRequest; + m_pendingChangeRequest.clear(); + for (int i = 0; i < changes.size(); ++i) { + changes[i].subject = subjects.at(i); + changes[i].messageCount = messageCounts.at(i); + } + + PendingChangesDialog(changes, this).exec(); +} + void MainWindow::updatePendingIndicator() { const int pending = pendingEditCount(); diff --git a/src/mainwindow.h b/src/mainwindow.h index 532a5ea..6321573 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -123,6 +123,13 @@ public: QVector<PendingChange> pendingChangeSnapshot() const; + /// Opens the list behind the unsynced-changes count. + /// + /// Takes the snapshot, asks the worker to resolve its subjects, and shows + /// the dialog when they arrive. Q_INVOKABLE so a test can open it without + /// synthesising a click on a status-bar label. + Q_INVOKABLE void showPendingChanges(); + /// Whether the undo stack still holds anything. Exposed so a test can show /// that a rejected write did not take unrelated history down with it. bool canUndo() const { return m_undoStack.canUndo(); } @@ -618,6 +625,10 @@ private slots: /// A tag mutation the worker has confirmed reached the database. Counts it /// as unsynced, since reaching the index is not reaching the mail store. void onTagsApplied(const TagChange &change); + + /// The subjects for the pending-changes list arrived; show the dialog. + void onPendingSubjectsResolved(const QStringList &subjects, + const QList<int> &messageCounts); void onAllTagsReady(const QStringList &tags); /// The Maildir root, answered once at startup. Enables nothing on its own: @@ -1575,6 +1586,13 @@ private: }; QHash<QString, PendingEdit> m_pendingTagEdits; + /// The snapshot taken when the user clicked the indicator, held while the + /// worker resolves its subjects. Empty when no such request is in flight. + /// + /// One request at a time: a second click before the first answers replaces + /// it, which is right because both would show the same thing. + QVector<PendingChange> m_pendingChangeRequest; + /// Marks the open thread read once it has been on screen long enough. /// /// Single-shot and RESTARTED on every selection change, never stacked: diff --git a/src/pendingchangesdialog.cpp b/src/pendingchangesdialog.cpp new file mode 100644 index 0000000..71e3bb0 --- /dev/null +++ b/src/pendingchangesdialog.cpp @@ -0,0 +1,121 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include "pendingchangesdialog.h" + +#include <QDialogButtonBox> +#include <QGridLayout> +#include <QLabel> +#include <QScrollArea> +#include <QVBoxLayout> + +QVector<PendingChangeRow> PendingChangesDialog::rowsFor( + const QVector<PendingChange> &changes) +{ + QVector<PendingChangeRow> rows; + rows.reserve(changes.size()); + + // A run is a stretch of changes sharing one id, which the snapshot has + // already grouped. Only the first row of a run carries a subject, so the + // actions read as belonging to the message above them. + // + // Compared against the PREVIOUS id rather than collected into a map: the + // snapshot's order is deliberate (the actions under one message keep the + // order they were made in), and a map would discard it. + QString previousId; + bool first = true; + for (const PendingChange &change : changes) { + const bool startsMessage = first || change.id != previousId; + rows.append(PendingChangeRow{ + startsMessage ? change.subject : QString(), + change.action, + startsMessage, + startsMessage ? change.messageCount : -1 }); + previousId = change.id; + first = false; + } + return rows; +} + +PendingChangesDialog::PendingChangesDialog( + const QVector<PendingChange> &changes, QWidget *parent) + : QDialog(parent), m_rows(rowsFor(changes)) +{ + setWindowTitle(tr("Unsynced changes")); + + auto *layout = new QVBoxLayout(this); + + auto *intro = new QLabel( + tr("Changes made here that a sync has not yet carried to the mail " + "store. This list is a snapshot taken when it was opened."), + this); + intro->setWordWrap(true); + layout->addWidget(intro); + + auto *content = new QWidget; + auto *grid = new QGridLayout(content); + grid->setColumnStretch(0, 1); + + int line = 0; + for (const PendingChangeRow &row : m_rows) { + if (row.startsMessage) { + // PlainText stated rather than left to Qt, for the reason + // MessageDetailsDialog states it on every value: a subject comes + // from a stranger, and a QLabel guesses under Qt::AutoText. Plain + // text cannot interpret markup, so there is nothing to escape. + QString text = row.subject; + if (text.isEmpty()) { + // The id no longer resolves. The row stays, because the count + // the user clicked has to equal the list they are shown. + text = tr("(no longer in the index)"); + } + if (row.messageCount >= 0) { + text = tr("%1 (whole thread, %n message(s))", "", + row.messageCount).arg(text); + } + auto *subject = new QLabel(text, content); + subject->setTextFormat(Qt::PlainText); + subject->setWordWrap(true); + grid->addWidget(subject, line, 0); + } + + auto *action = new QLabel(row.action, content); + action->setTextFormat(Qt::PlainText); + grid->addWidget(action, line, 1, Qt::AlignTop | Qt::AlignRight); + ++line; + } + + if (m_rows.isEmpty()) { + grid->addWidget(new QLabel(tr("Nothing is waiting to be synced."), + content), + 0, 0); + } + + grid->setRowStretch(line, 1); + + auto *scroll = new QScrollArea(this); + scroll->setWidget(content); + scroll->setWidgetResizable(true); + layout->addWidget(scroll); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + layout->addWidget(buttons); + + resize(600, 380); +} diff --git a/src/pendingchangesdialog.h b/src/pendingchangesdialog.h new file mode 100644 index 0000000..5e47cbd --- /dev/null +++ b/src/pendingchangesdialog.h @@ -0,0 +1,86 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#pragma once + +#include <QDialog> +#include <QVector> + +#include "types.h" + +/// What one line of the dialog shows. +/// +/// Separate from PendingChange because the two answer different questions. +/// PendingChange is what is outstanding; this is what is drawn, and the +/// difference is the grouping: a message with several actions contributes +/// several rows here, only the first of which carries a subject. +struct PendingChangeRow +{ + /// The subject, drawn only on the first row of a run sharing one id. + /// Empty on the rows beneath it, which is what puts the actions under + /// their message rather than beside a repeated subject. + QString subject; + + /// What the user did. Every row has one; this is the point of the list. + QString action; + + /// True when this row opens a new message, i.e. when `subject` is drawn. + /// Carried explicitly rather than inferred from a non-empty subject: a + /// message whose id no longer resolves has an EMPTY subject and still + /// opens a run of its own. + bool startsMessage = false; + + /// How many messages a thread row covered, or -1 for a message row. + int messageCount = -1; +}; + +/// The list behind the unsynced-changes count (item 119). +/// +/// Read-only, deliberately. This is an information window, not a place to +/// retry or discard a change: either would be a new mutation path with its own +/// undo question, and the count exists to answer "is my work safe to quit on" +/// rather than to be edited. +/// +/// A SNAPSHOT. The rows are built once, when the user opens it, and never +/// refreshed underneath them: a dialog left open for twenty minutes shows what +/// was true when it was opened, which is what the user clicked on. +/// +/// Rows are exposed so the grouping can be asserted without rendering +/// anything, which is how MessageDetailsDialog is tested and for the same +/// reason: a pixel probe cannot tell a correct layout from a plausible one. +class PendingChangesDialog : public QDialog +{ + Q_OBJECT +public: + explicit PendingChangesDialog(const QVector<PendingChange> &changes, + QWidget *parent = nullptr); + + /// The lines on display, in order. Exposed for testing without rendering. + QVector<PendingChangeRow> rows() const { return m_rows; } + + /// Groups the changes into display rows: a subject on the first row of + /// each run sharing an id, the actions beneath it. + /// + /// Static and value-in, value-out so the grouping is testable with no + /// widget at all. + static QVector<PendingChangeRow> rowsFor( + const QVector<PendingChange> &changes); + +private: + QVector<PendingChangeRow> m_rows; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 69c57ee..2cb3651 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,6 +52,7 @@ add_qtmaildir_test(htmlbuilder) add_qtmaildir_test(notmuchworker) add_qtmaildir_test(tagcolors) add_qtmaildir_test(cardlayout) +add_qtmaildir_test(pendingchangesdialog) add_qtmaildir_test(avatar) add_qtmaildir_test(businesssenders) add_qtmaildir_test(marks) diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index be19652..a572fd0 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -47,6 +47,7 @@ #include "config.h" #include "keymap.h" #include "mainwindow.h" +#include "pendingchangesdialog.h" #include "messageview.h" #include "mimeparser.h" #include "notmuchworker.h" @@ -418,6 +419,7 @@ private slots: void everyPendingChangeCanNameItsMessages(); void theSnapshotGroupsActionsUnderTheirMessage(); void theSnapshotKeepsAThreadActionThreadScoped(); + void theIndicatorOpensItsListOnAClick(); void anEditDuringABackgroundSyncIsNotSentYet(); void aHeldEditIsSentWhenTheBackgroundSyncEnds(); void aHeldEditCountsAsUnsynced(); @@ -6574,6 +6576,52 @@ void TestMainWindow::theSnapshotKeepsAThreadActionThreadScoped() QCOMPARE(rows.size(), window.pendingEditCount()); } +void TestMainWindow::theIndicatorOpensItsListOnAClick() +{ + // The label is a QLabel and has no clicked signal, so the click is taken + // by an event filter. A test that called showPendingChanges() directly + // would pass with that filter never installed, which is the whole gesture. + const Config config; + MainWindow window(config); + + auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits")); + QVERIFY(label); + + TagChange change; + change.messageIds = { QStringLiteral("click@example.org") }; + change.added = { QStringLiteral("deleted") }; + change.description = QStringLiteral("Delete"); + QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied", + Q_ARG(TagChange, change))); + QVERIFY(!label->isHidden()); + + // With no worker the dialog opens directly and modally, so it is closed + // from a timer rather than by driving exec() to return some other way. + // Polled rather than checked once: exec() parents the dialog and spins its + // own event loop, so a single-shot timer can fire before it exists. + bool sawDialog = false; + auto *poll = new QTimer(&window); + poll->setInterval(1); + QObject::connect(poll, &QTimer::timeout, &window, [&window, &sawDialog]() { + if (auto *dialog = window.findChild<PendingChangesDialog *>()) { + sawDialog = true; + QCOMPARE(dialog->rows().size(), 1); + QCOMPARE(dialog->rows().at(0).action, QStringLiteral("Delete")); + dialog->reject(); + } + }); + poll->start(); + + QMouseEvent press(QEvent::MouseButtonRelease, QPointF(1, 1), + QPointF(1, 1), Qt::LeftButton, Qt::LeftButton, + Qt::NoModifier); + QCoreApplication::sendEvent(label, &press); + + // The subjects are resolved on the worker thread, so the dialog appears a + // round trip after the click rather than inside sendEvent(). + QTRY_VERIFY_WITH_TIMEOUT(sawDialog, 15000); +} + // Item 37. A tag edit made while a background sync holds notmuch's write lock // used to stall the worker: the read-write open BLOCKS until the lock frees // (measured 9.158s against a 12s hold, returning NOTMUCH_STATUS_SUCCESS), so diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index bcde45c..2e960be 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -58,6 +58,7 @@ private slots: void applyTagsToThreadsSpansMultipleThreads(); void applyTagsToThreadsWithNoThreadsDoesNothing(); + void pendingSubjectsCrossAQueuedCall(); void pendingSubjectsAnswerPositionally(); void aMissingPendingIdYieldsAnEmptySubject(); void requestAllTagsReturnsSortedTags(); @@ -964,6 +965,40 @@ void TestNotmuchWorker::applyTagsToThreadsWithNoThreadsDoesNothing() QVERIFY(errors.isEmpty()); } +void TestNotmuchWorker::pendingSubjectsCrossAQueuedCall() +{ + // The dialog reaches the worker over a QUEUED connection, and a container + // whose metatype is not registered under the name invokeMethod resolves is + // DROPPED at runtime with a warning, leaving the slot to run with a + // default. CLAUDE.md records that trap for Q_ENUM; QList<int> is the same + // trap in a different shape, and it is the type this signal answers with. + // + // Driven through invokeMethod on a real thread rather than by calling the + // slot directly: a direct call proves nothing about the queued path, which + // is the only one production uses. + NotmuchWorker worker(m_fixture.configPath()); + QThread thread; + worker.moveToThread(&thread); + thread.start(); + + QSignalSpy spy(&worker, &NotmuchWorker::pendingSubjectsResolved); + QVERIFY(QMetaObject::invokeMethod( + &worker, "resolvePendingSubjects", Qt::QueuedConnection, + Q_ARG(QStringList, QStringList{ QStringLiteral("b1@example.org") }), + Q_ARG(QList<bool>, QList<bool>{ false }))); + + QVERIFY2(spy.wait(5000), + "the queued call never produced an answer: a container argument " + "was most likely dropped for want of a registered metatype"); + QCOMPARE(spy.first().at(0).toStringList().size(), 1); + QVERIFY(!spy.first().at(0).toStringList().at(0).isEmpty()); + // And the counts survived the crossing as a real list, not a default. + QCOMPARE(spy.first().at(1).value<QList<int>>().size(), 1); + + thread.quit(); + QVERIFY(thread.wait(5000)); +} + void TestNotmuchWorker::pendingSubjectsAnswerPositionally() { // Item 119. The dialog has already decided what its rows are and in what diff --git a/tests/test_pendingchangesdialog.cpp b/tests/test_pendingchangesdialog.cpp new file mode 100644 index 0000000..078f6a8 --- /dev/null +++ b/tests/test_pendingchangesdialog.cpp @@ -0,0 +1,149 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include <QtTest> + +#include "pendingchangesdialog.h" + +/// The grouping the user asked for, asserted on the ROWS rather than on a +/// render. A pixel probe cannot tell a correct layout from a plausible one, +/// which is why MessageDetailsDialog exposes its rows too. +class TestPendingChangesDialog : public QObject +{ + Q_OBJECT +private slots: + void aMessageIsDrawnOnceWithItsActionsBeneath(); + void everyChangeKeepsARowOfItsOwn(); + void aThreadRowCarriesItsMessageCount(); + void anUnresolvedIdStillOpensItsRun(); +}; + +void TestPendingChangesDialog::aMessageIsDrawnOnceWithItsActionsBeneath() +{ + // The layout: subject once, actions under it. + // + // Build fails Delete + // Mark read + // August digest Delete + const QVector<PendingChange> changes { + { QStringLiteral("a@example.org"), false, QStringLiteral("Delete"), + QStringLiteral("Build fails"), -1 }, + { QStringLiteral("a@example.org"), false, QStringLiteral("Mark read"), + QStringLiteral("Build fails"), -1 }, + { QStringLiteral("b@example.org"), false, QStringLiteral("Delete"), + QStringLiteral("August digest"), -1 }, + }; + + const QVector<PendingChangeRow> rows = + PendingChangesDialog::rowsFor(changes); + QCOMPARE(rows.size(), 3); + + // First row of the run carries the subject. + QVERIFY(rows.at(0).startsMessage); + QCOMPARE(rows.at(0).subject, QStringLiteral("Build fails")); + QCOMPARE(rows.at(0).action, QStringLiteral("Delete")); + + // The second action of the same message carries NO subject, which is what + // puts it under the message rather than beside a repeated one. + QVERIFY(!rows.at(1).startsMessage); + QVERIFY2(rows.at(1).subject.isEmpty(), + "the subject was repeated instead of grouping the actions"); + QCOMPARE(rows.at(1).action, QStringLiteral("Mark read")); + + // A different message opens a new run. + QVERIFY(rows.at(2).startsMessage); + QCOMPARE(rows.at(2).subject, QStringLiteral("August digest")); +} + +void TestPendingChangesDialog::everyChangeKeepsARowOfItsOwn() +{ + // Grouping must not COLLAPSE anything: the count the user clicked has to + // equal the number of rows they are shown, so two actions on one message + // are two rows however they are drawn. + const QVector<PendingChange> changes { + { QStringLiteral("a@example.org"), false, QStringLiteral("Delete"), + QStringLiteral("One"), -1 }, + { QStringLiteral("a@example.org"), false, QStringLiteral("Mark read"), + QStringLiteral("One"), -1 }, + { QStringLiteral("a@example.org"), false, QStringLiteral("Mark spam"), + QStringLiteral("One"), -1 }, + }; + + const QVector<PendingChangeRow> rows = + PendingChangesDialog::rowsFor(changes); + QCOMPARE(rows.size(), changes.size()); + + // Exactly one of them opens the run, and every action survives. + int starts = 0; + QStringList actions; + for (const PendingChangeRow &row : rows) { + if (row.startsMessage) + ++starts; + actions.append(row.action); + } + QCOMPARE(starts, 1); + QCOMPARE(actions, QStringList({ QStringLiteral("Delete"), + QStringLiteral("Mark read"), + QStringLiteral("Mark spam") })); +} + +void TestPendingChangesDialog::aThreadRowCarriesItsMessageCount() +{ + // A thread action reports how many messages it covered. The count belongs + // to the row that opens the run, since that is where the subject is drawn. + const QVector<PendingChange> changes { + { QStringLiteral("t1"), true, QStringLiteral("Delete thread"), + QStringLiteral("A conversation"), 4 }, + { QStringLiteral("m1@example.org"), false, QStringLiteral("Delete"), + QStringLiteral("A message"), -1 }, + }; + + const QVector<PendingChangeRow> rows = + PendingChangesDialog::rowsFor(changes); + QCOMPARE(rows.size(), 2); + QCOMPARE(rows.at(0).messageCount, 4); + // A message row claims no count: it stands for one message and saying "1" + // would read as a thread of one. + QCOMPARE(rows.at(1).messageCount, -1); +} + +void TestPendingChangesDialog::anUnresolvedIdStillOpensItsRun() +{ + // A stale id resolves to an empty subject. The row must still OPEN a run, + // or its actions would be drawn as though they belonged to the message + // above them, which is worse than saying the subject is unknown. + // + // This is why startsMessage is carried rather than inferred from a + // non-empty subject. + const QVector<PendingChange> changes { + { QStringLiteral("a@example.org"), false, QStringLiteral("Delete"), + QStringLiteral("Known"), -1 }, + { QStringLiteral("gone@example.org"), false, QStringLiteral("Delete"), + QString(), -1 }, + }; + + const QVector<PendingChangeRow> rows = + PendingChangesDialog::rowsFor(changes); + QCOMPARE(rows.size(), 2); + QVERIFY2(rows.at(1).startsMessage, + "an unresolved id was folded into the message above it"); + QVERIFY(rows.at(1).subject.isEmpty()); +} + +QTEST_MAIN(TestPendingChangesDialog) +#include "test_pendingchangesdialog.moc" diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts index 0d2c36f..85b978f 100644 --- a/translations/qtmaildir_it_IT.ts +++ b/translations/qtmaildir_it_IT.ts @@ -1622,6 +1622,32 @@ Il messaggio È stato inviato. Non inviarlo di nuovo.</translation> </message> </context> <context> + <name>PendingChangesDialog</name> + <message> + <source>Unsynced changes</source> + <translation>Modifiche non sincronizzate</translation> + </message> + <message> + <source>Changes made here that a sync has not yet carried to the mail store. This list is a snapshot taken when it was opened.</source> + <translation>Modifiche fatte qui che una sincronizzazione non ha ancora portato all'archivio di posta. Questo elenco è un'istantanea presa al momento dell'apertura.</translation> + </message> + <message> + <source>(no longer in the index)</source> + <translation>(non più nell'indice)</translation> + </message> + <message numerus="yes"> + <source>%1 (whole thread, %n message(s))</source> + <translation> + <numerusform>%1 (intera conversazione, %n messaggio)</numerusform> + <numerusform>%1 (intera conversazione, %n messaggi)</numerusform> + </translation> + </message> + <message> + <source>Nothing is waiting to be synced.</source> + <translation>Non c'è nulla in attesa di sincronizzazione.</translation> + </message> +</context> +<context> <name>QObject</name> <message> <source>qtmaildir</source> |
