aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_tagrules.cpp376
1 files changed, 370 insertions, 6 deletions
diff --git a/tests/test_tagrules.cpp b/tests/test_tagrules.cpp
index ed3a06d..cc28711 100644
--- a/tests/test_tagrules.cpp
+++ b/tests/test_tagrules.cpp
@@ -38,6 +38,15 @@ private slots:
void aRuleLoadsWithEveryField();
void absentFieldsTakeTheirDefaults();
void aMalformedRuleIsDroppedWithAWarning();
+ void aRuleWithABadIdLoadsForRepairRatherThanVanishing();
+ void aTypedNameIsSanitisedIntoAnId();
+ void aSanitisedNameThatCollidesGetsItsOwnId();
+ void savingIsRefusedWhenARuleWouldNotLoadBack();
+ void aRepairedIdSurvivesASaveAndReload();
+ void theWarningReadsAsAWarningAndSitsBesideSave();
+ void aDismissedWarningComesBackWhenThereIsSomethingNewToSay();
+ void aNameTypedWithSpacesIsSanitisedInTheField();
+ void aRuleAddedAndNamedInTheDialogSurvivesAReopen();
void unknownFieldsSurviveASave();
void stageOrderPutsAccountsFirst();
void aQueryWithQuotesRoundTrips();
@@ -128,16 +137,19 @@ void TestTagRules::absentFieldsTakeTheirDefaults()
void TestTagRules::aMalformedRuleIsDroppedWithAWarning()
{
- // One bad rule must not cost the others. Four separate defects, and the
- // good rule sits first so a parser that stops at the first problem is
- // caught by the count rather than by an empty list.
+ // One bad rule must not cost the others. The good rule sits first so a
+ // parser that stops at the first problem is caught by the count rather
+ // than by an empty list.
+ //
+ // A rule with nothing to run is still dropped: no query and no tags are
+ // both unrepairable without inventing the user's intent. A rule whose only
+ // fault is its ID is NOT dropped any more, see the next test.
const QString path = writeRules(R"({
"version": 1,
"rules": [
{"id": "good", "add": ["x"], "query": "from:a@example.com"},
{"id": "no-query", "add": ["y"]},
- {"id": "no-tags", "query": "from:b@example.com"},
- {"id": "Bad Id", "add": ["z"], "query": "from:c@example.com"}
+ {"id": "no-tags", "query": "from:b@example.com"}
]
})");
@@ -146,7 +158,164 @@ void TestTagRules::aMalformedRuleIsDroppedWithAWarning()
QCOMPARE(rules.rules().size(), 1);
QCOMPARE(rules.rules().first().id, QStringLiteral("good"));
- QCOMPARE(rules.warnings().size(), 3);
+ QCOMPARE(rules.warnings().size(), 2);
+}
+
+void TestTagRules::aRuleWithABadIdLoadsForRepairRatherThanVanishing()
+{
+ // The defect this whole change exists for. A rule saved with a space in
+ // its id was written to the file correctly, dropped on every load, and so
+ // was invisible in the dialog while still occupying the file. The next
+ // save from the dialog would then have deleted it for good.
+ //
+ // It now loads, carrying its repaired id, so the dialog can show it and
+ // the user can fix it. The warning still fires: the file on disk is not
+ // what the hook will run until it is saved back.
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [
+ {"id": "justeat orders", "add": ["promo"],
+ "query": "from:no-reply@order.example.com"}
+ ]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+
+ QCOMPARE(rules.rules().size(), 1);
+ QCOMPARE(rules.rules().first().id, QStringLiteral("justeat-orders"));
+ QCOMPARE(rules.rules().first().add, QStringList{ QStringLiteral("promo") });
+ QCOMPARE(rules.warnings().size(), 1);
+ QVERIFY(rules.warnings().first().contains(QStringLiteral("justeat orders")));
+}
+
+void TestTagRules::aTypedNameIsSanitisedIntoAnId()
+{
+ // Spaces, capitals and punctuation are what a person types into a field
+ // labelled "Name". Each case here is one the user is likely to produce,
+ // and every result has to satisfy ^[a-z0-9][a-z0-9-]*$ or the hook drops
+ // it.
+ QCOMPARE(TagRules::sanitiseId(QStringLiteral("justeat orders")),
+ QStringLiteral("justeat-orders"));
+ QCOMPARE(TagRules::sanitiseId(QStringLiteral("JustEat Orders")),
+ QStringLiteral("justeat-orders"));
+ QCOMPARE(TagRules::sanitiseId(QStringLiteral("Notify: PayPal!")),
+ QStringLiteral("notify-paypal"));
+ QCOMPARE(TagRules::sanitiseId(QStringLiteral(" spaced out ")),
+ QStringLiteral("spaced-out"));
+ QCOMPARE(TagRules::sanitiseId(QStringLiteral("-leading-dash")),
+ QStringLiteral("leading-dash"));
+
+ // A run of dashes is collapsed only when the name needed sanitising at
+ // all: "a---b" already satisfies the pattern and is left exactly as it is,
+ // because rewriting legal ids would churn the file mailctl also reads.
+ QCOMPARE(TagRules::sanitiseId(QStringLiteral("a---b")),
+ QStringLiteral("a---b"));
+ QCOMPARE(TagRules::sanitiseId(QStringLiteral("a - - b")),
+ QStringLiteral("a-b"));
+ QCOMPARE(TagRules::sanitiseId(QStringLiteral("mailing-list/SBo")),
+ QStringLiteral("mailing-list-sbo"));
+
+ // An id may not START with a dash or a digit-less symbol run, and a name
+ // made only of punctuation sanitises to nothing. Empty is not a legal id,
+ // so the caller has to supply a fallback rather than writing one out.
+ QCOMPARE(TagRules::sanitiseId(QStringLiteral("!!!")), QString());
+ QCOMPARE(TagRules::sanitiseId(QString()), QString());
+
+ // Already valid ids pass through untouched, or every load would rewrite
+ // the file and show mailctl a diff the user never made.
+ QCOMPARE(TagRules::sanitiseId(QStringLiteral("notify-github")),
+ QStringLiteral("notify-github"));
+}
+
+void TestTagRules::aSanitisedNameThatCollidesGetsItsOwnId()
+{
+ // Sanitising maps many names onto one id, so it can manufacture the exact
+ // duplicate that load() drops. "Justeat Orders" and "justeat orders" both
+ // reduce to justeat-orders; the second must not silently become the first.
+ const QStringList taken{ QStringLiteral("justeat-orders"),
+ QStringLiteral("justeat-orders-2") };
+
+ QCOMPARE(TagRules::uniqueId(QStringLiteral("Justeat Orders"), taken),
+ QStringLiteral("justeat-orders-3"));
+
+ // No collision means no suffix.
+ QCOMPARE(TagRules::uniqueId(QStringLiteral("promo"), taken),
+ QStringLiteral("promo"));
+
+ // A name that sanitises to nothing still has to produce a legal id.
+ const QString fallback = TagRules::uniqueId(QStringLiteral("!!!"), taken);
+ QVERIFY(!fallback.isEmpty());
+ QVERIFY(TagRules::isValidId(fallback));
+}
+
+void TestTagRules::savingIsRefusedWhenARuleWouldNotLoadBack()
+{
+ // The asymmetry that caused the bug: save wrote anything, load validated.
+ // validate() is the one predicate both sides now use, so a rule that
+ // would not survive a reload is reported BEFORE it reaches the file.
+ TagRule good;
+ good.id = QStringLiteral("good");
+ good.query = QStringLiteral("from:a@example.com");
+ good.add = { QStringLiteral("x") };
+
+ TagRule noQuery;
+ noQuery.id = QStringLiteral("no-query");
+ noQuery.add = { QStringLiteral("y") };
+
+ TagRule noTags;
+ noTags.id = QStringLiteral("no-tags");
+ noTags.query = QStringLiteral("from:b@example.com");
+
+ TagRule badId;
+ badId.id = QStringLiteral("Bad Id");
+ badId.query = QStringLiteral("from:c@example.com");
+ badId.add = { QStringLiteral("z") };
+
+ QVERIFY(TagRules::validate({ good }).isEmpty());
+
+ const QStringList problems =
+ TagRules::validate({ good, noQuery, noTags, badId });
+ QCOMPARE(problems.size(), 3);
+ QVERIFY(problems.join(QChar(' ')).contains(QStringLiteral("no-query")));
+ QVERIFY(problems.join(QChar(' ')).contains(QStringLiteral("no-tags")));
+ QVERIFY(problems.join(QChar(' ')).contains(QStringLiteral("Bad Id")));
+
+ // A duplicate id survives a save and is then dropped on load, so it is a
+ // save-time problem too even though each rule is fine on its own.
+ TagRule twin = good;
+ QCOMPARE(TagRules::validate({ good, twin }).size(), 1);
+}
+
+void TestTagRules::aRepairedIdSurvivesASaveAndReload()
+{
+ // End to end, and the assertion that matters to the user: the rule they
+ // could not keep is still there after the round trip, with its tags, its
+ // query and its note intact.
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [
+ {"id": "justeat orders", "add": ["promo"], "stage": 50,
+ "note": "kept", "query": "from:no-reply@order.example.com"}
+ ]
+ })");
+
+ TagRules loaded;
+ loaded.load(path);
+ QCOMPARE(loaded.rules().size(), 1);
+ QVERIFY(loaded.save(path));
+
+ TagRules reread;
+ reread.load(path);
+ QCOMPARE(reread.rules().size(), 1);
+ QCOMPARE(reread.rules().first().id, QStringLiteral("justeat-orders"));
+ QCOMPARE(reread.rules().first().note, QStringLiteral("kept"));
+ QCOMPARE(reread.rules().first().query,
+ QStringLiteral("from:no-reply@order.example.com"));
+
+ // Repaired on the way in, so the second read has nothing left to complain
+ // about. A warning that never clears trains the user to ignore it.
+ QVERIFY(reread.warnings().isEmpty());
}
void TestTagRules::unknownFieldsSurviveASave()
@@ -560,6 +729,201 @@ void TestTagRules::leavingTextModeIsRefusedWhenTheQueryCannotBeShownAsRows()
"a stale refusal must not outlive the query that caused it");
}
+void TestTagRules::theWarningReadsAsAWarningAndSitsBesideSave()
+{
+ // The label was correct and unread: same font and colour as the intro
+ // prose two lines above it, so it looked like more explanation. The user
+ // opened this dialog repeatedly, with the warning showing every time,
+ // while hunting the rule it was telling them about.
+ //
+ // Asserted on the widget's own properties, not on a render. CLAUDE.md
+ // records why a pixel probe cannot carry this: counting lit pixels cannot
+ // tell one colour from another reliably, and viewport()->render() returns
+ // blank often enough that a probe reporting "no red anywhere" says more
+ // about the probe than the code.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ const QString stored = configHome.filePath(
+ QStringLiteral("mailrules/rules.json"));
+ QFile out(stored);
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ // A rule that warns on load, so the label is populated by opening alone.
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "justeat orders", "query": "from:no-reply@order.example.com",
+ "add": ["promo"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+
+ // The guard. Everything below asserts about a warning that is showing, and
+ // all of it would pass vacuously against a label that never appears.
+ QVERIFY2(!dialog.warningTextForTest().isEmpty(),
+ "a repaired rule must warn, or this test proves nothing");
+
+ QVERIFY2(dialog.warningTextForTest().contains(QStringLiteral("justeat")),
+ "the warning must name the rule it is about");
+
+ const QString style = dialog.warningStyleForTest();
+ QVERIFY2(style.contains(QStringLiteral("background-color")),
+ "a warning that is not filled reads as ordinary prose");
+ QVERIFY2(style.contains(QStringLiteral("bold")), "and it must be bold");
+
+ // Below the rule list, next to the button whose outcome it reports. The
+ // intro sits at the top, so comparing against it pins the move: this
+ // assertion fails if the label drifts back under the header.
+ QVERIFY2(dialog.warningIsBelowTheRuleListForTest(),
+ "the warning belongs beside Save, not under the intro text");
+
+ // Plain text, because the strings interpolate ids and queries read from
+ // the file. A query holding '<' would otherwise be swallowed as markup.
+ QCOMPARE(dialog.warningTextFormatForTest(), Qt::PlainText);
+}
+
+void TestTagRules::aDismissedWarningComesBackWhenThereIsSomethingNewToSay()
+{
+ // Dismissal is per-appearance. The warning most often says the file is not
+ // yet what the hook runs, so a persistent "do not show again" would rehide
+ // the exact problem that went unnoticed for a session. Closing it clears
+ // this one; the next thing worth saying shows it again.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ const QString stored = configHome.filePath(
+ QStringLiteral("mailrules/rules.json"));
+ QFile out(stored);
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "justeat orders", "query": "from:no-reply@order.example.com",
+ "add": ["promo"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+ QVERIFY2(!dialog.warningTextForTest().isEmpty(),
+ "the repaired rule must warn, or the dismissal proves nothing");
+
+ dialog.dismissWarningForTest();
+ QVERIFY2(dialog.warningTextForTest().isEmpty(),
+ "the X must actually clear the warning");
+
+ // Something new to say: a rule that cannot be saved. The dismissal must
+ // not have latched the banner shut.
+ dialog.setNameForTest(QStringLiteral("second"));
+ dialog.addRuleForTest();
+ dialog.setTagsForTest(QString());
+ dialog.setTextModeForTest(true);
+ dialog.setQueryTextForTest(QString());
+ dialog.saveForTest();
+
+ QVERIFY2(!dialog.warningTextForTest().isEmpty(),
+ "a refusal after a dismissal must still be shown");
+}
+
+void TestTagRules::aNameTypedWithSpacesIsSanitisedInTheField()
+{
+ // The field is labelled "Name", so a person types prose into it. What the
+ // field SHOWS after the edit is committed is the assertion: sanitising
+ // silently on save would leave the user looking at a name that is not the
+ // one being written.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ const QString stored = configHome.filePath(
+ QStringLiteral("mailrules/rules.json"));
+ QFile out(stored);
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "vendor", "query": "from:vendor.example.org",
+ "add": ["vendor"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+ dialog.setNameForTest(QStringLiteral("Justeat orders"));
+ QCOMPARE(dialog.nameLineForTest(), QStringLiteral("justeat-orders"));
+
+ dialog.saveForTest();
+
+ TagRules reloaded;
+ reloaded.load(stored);
+ QCOMPARE(reloaded.rules().size(), 1);
+ QCOMPARE(reloaded.rules().first().id, QStringLiteral("justeat-orders"));
+ QVERIFY2(reloaded.warnings().isEmpty(),
+ "a rule saved from the dialog must load back without complaint");
+}
+
+void TestTagRules::aRuleAddedAndNamedInTheDialogSurvivesAReopen()
+{
+ // The user's session, end to end: add a rule, name it in prose, fill in
+ // the query and tags, save, reopen. Before the fix the rule was written to
+ // the file with a space in its id and dropped by every reader, so the
+ // dialog came back without it and the file kept a rule nothing would run.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ const QString stored = configHome.filePath(
+ QStringLiteral("mailrules/rules.json"));
+ QFile out(stored);
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "vendor", "query": "from:vendor.example.org",
+ "add": ["vendor"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ {
+ TagRulesDialog dialog;
+ QCOMPARE(dialog.ruleCountForTest(), 1);
+
+ dialog.addRuleForTest();
+ QCOMPARE(dialog.ruleCountForTest(), 2);
+
+ dialog.setNameForTest(QStringLiteral("Justeat orders"));
+ dialog.setTextModeForTest(true);
+ dialog.setQueryTextForTest(
+ QStringLiteral("from:no-reply@order.example.com"));
+ dialog.setTagsForTest(QStringLiteral("promo, notify/justeat"));
+ dialog.saveForTest();
+
+ QVERIFY2(dialog.warningTextForTest().isEmpty(),
+ "a complete rule must not be refused");
+ }
+
+ TagRules reloaded;
+ reloaded.load(stored);
+ QCOMPARE(reloaded.rules().size(), 2);
+
+ const TagRule added = reloaded.rules().at(1);
+ QCOMPARE(added.id, QStringLiteral("justeat-orders"));
+ QCOMPARE(added.query,
+ QStringLiteral("from:no-reply@order.example.com"));
+ QCOMPARE(added.add, (QStringList{ QStringLiteral("promo"),
+ QStringLiteral("notify/justeat") }));
+ QVERIFY(reloaded.warnings().isEmpty());
+}
+
void TestTagRules::aFolderRowUsesTheDropdownAndKeepsItsSuffix()
{
// A path: without its suffix matches nothing and notmuch says nothing