<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\tests;
use seekquarry\yioop\library\mail\ImapListing;
use seekquarry\yioop\library\UnitTest;
/**
* Unit tests for ImapListing, the socket-free decision and shaping
* logic behind a mailbox view. Each case checks one rule that keeps a
* folder listing correct, fast, or resilient, with no live mail
* server needed.
*
* @author Chris Pollett
*/
class ImapListingTest extends UnitTest
{
/**
* No setUp needed; every method under test is a pure static
*/
public function setUp()
{
}
/**
* No tearDown
*/
public function tearDown()
{
}
/**
* searchCriteria should return ALL when nothing narrows the view,
* stack the unread and flagged toggles as space-separated terms,
* and turn a free-text term into an OR over subject and sender
* with the term safely quoted.
*/
public function searchCriteriaTestCase()
{
$this->assertEqual('ALL',
ImapListing::searchCriteria('', false, false),
"no filter and no toggles matches everything");
$this->assertEqual('UNSEEN',
ImapListing::searchCriteria('', true, false),
"unread-only becomes UNSEEN");
$this->assertEqual('FLAGGED',
ImapListing::searchCriteria('', false, true),
"flagged-only becomes FLAGGED");
$this->assertEqual('UNSEEN FLAGGED',
ImapListing::searchCriteria('', true, true),
"both toggles stack as space-separated AND terms");
$this->assertEqual('OR SUBJECT "hi" FROM "hi"',
ImapListing::searchCriteria('hi', false, false),
"a term searches subject or sender");
$this->assertEqual(
'UNSEEN OR SUBJECT "hi" FROM "hi"',
ImapListing::searchCriteria('hi', true, false),
"toggle and term combine");
$this->assertEqual(
'OR SUBJECT "a\\"b" FROM "a\\"b"',
ImapListing::searchCriteria('a"b', false, false),
"a quote in the term is escaped so it cannot break out");
}
/**
* sortSignature should give each distinct sort-and-toggle
* combination its own tag so cached orderings do not collide.
*/
public function sortSignatureTestCase()
{
$this->assertEqual('date',
ImapListing::sortSignature(['key' => 'date',
'reverse' => false], false, false),
"plain date sort");
$this->assertEqual('date-rev',
ImapListing::sortSignature(['key' => 'date',
'reverse' => true], false, false),
"reversed date sort is distinct");
$this->assertEqual('subject-unread-flagged',
ImapListing::sortSignature(['key' => 'subject',
'reverse' => false], true, true),
"key and both toggles all appear in the tag");
}
/**
* serverSortPlan should ask for no SORT in the common newest-
* first-by-date case and otherwise return the exact SORT
* arguments, getting the per-key direction right so date and the
* text fields each come out the way a reader expects.
*/
public function serverSortPlanTestCase()
{
$this->assertEqual(['mode' => 'search'],
ImapListing::serverSortPlan(['key' => 'date',
'reverse' => false]),
"newest-first date needs no SORT command");
$this->assertEqual(['mode' => 'sort', 'args' => 'DATE'],
ImapListing::serverSortPlan(['key' => 'date',
'reverse' => true]),
"oldest-first date sorts ascending with no REVERSE");
$this->assertEqual(['mode' => 'sort', 'args' => 'SUBJECT'],
ImapListing::serverSortPlan(['key' => 'subject',
'reverse' => false]),
"A-to-Z subject sorts ascending with no REVERSE");
$this->assertEqual(
['mode' => 'sort', 'args' => 'REVERSE SUBJECT'],
ImapListing::serverSortPlan(['key' => 'subject',
'reverse' => true]),
"Z-to-A subject asks the server to REVERSE");
$this->assertEqual(['mode' => 'sort', 'args' => 'FROM'],
ImapListing::serverSortPlan(['key' => 'from',
'reverse' => false]),
"A-to-Z sender sorts ascending with no REVERSE");
$this->assertEqual(
['mode' => 'sort', 'args' => 'REVERSE FROM'],
ImapListing::serverSortPlan(['key' => 'from',
'reverse' => true]),
"Z-to-A sender asks the server to REVERSE");
}
/**
* fallbackDateOrder should treat sequence numbers as arrival
* order, returning newest-first by default and oldest-first when
* the view asks for it.
*/
public function fallbackDateOrderTestCase()
{
$this->assertEqual([3, 2, 1],
ImapListing::fallbackDateOrder([1, 2, 3], false),
"default is newest-first (highest sequence first)");
$this->assertEqual([1, 2, 3],
ImapListing::fallbackDateOrder([1, 2, 3], true),
"reverse keeps oldest-first order");
}
/**
* fallbackFieldOrder should order by the chosen header value
* case-insensitively, break ties by sequence number for a stable
* result, use the sender display name for the from key, and flip
* the whole order when reversed.
*/
public function fallbackFieldOrderTestCase()
{
$values = [1 => "Banana", 2 => "apple", 3 => "Cherry"];
$this->assertEqual([2, 1, 3],
ImapListing::fallbackFieldOrder([1, 2, 3], $values,
'subject', false),
"case-insensitive A-to-Z by subject");
$this->assertEqual([3, 1, 2],
ImapListing::fallbackFieldOrder([1, 2, 3], $values,
'subject', true),
"reverse gives Z-to-A");
$tie = [5 => "same", 2 => "same", 9 => "same"];
$this->assertEqual([2, 5, 9],
ImapListing::fallbackFieldOrder([5, 2, 9], $tie,
'subject', false),
"equal values fall back to sequence-number order");
$from = [1 => '"Zara Young" <zara@example.com>',
2 => 'Abe <abe@example.com>'];
$this->assertEqual([2, 1],
ImapListing::fallbackFieldOrder([1, 2], $from, 'from',
false),
"from sort compares the display name, not the address");
}
/**
* extractFromName should return a quoted or unquoted display
* name when present and otherwise fall back to the local part of
* the address.
*/
public function extractFromNameTestCase()
{
$this->assertEqual('Ada Lovelace',
ImapListing::extractFromName(
'"Ada Lovelace" <ada@example.com>'),
"quoted display name is unwrapped");
$this->assertEqual('Bob',
ImapListing::extractFromName('Bob <bob@example.com>'),
"unquoted display name is returned");
$this->assertEqual('carol',
ImapListing::extractFromName('carol@example.com'),
"bare address falls back to the local part");
$this->assertEqual('dave',
ImapListing::extractFromName('<dave@example.com>'),
"angle-bracketed bare address falls back to local part");
$this->assertEqual('',
ImapListing::extractFromName(' '),
"blank input yields the empty string");
}
/**
* The batch-size policy should start modest and capped by the
* total, double on each success up to the amount still wanted,
* and halve on failure without dropping below one.
*/
public function batchPolicyTestCase()
{
$this->assertEqual(0, ImapListing::initialBatch(0),
"no messages means a zero first batch");
$this->assertEqual(10, ImapListing::initialBatch(10),
"a small total is taken whole");
$this->assertEqual(25, ImapListing::initialBatch(100),
"a large total is capped at the initial batch size");
$this->assertEqual(2, ImapListing::growBatch(100, 1),
"after one success the batch is two");
$this->assertEqual(8, ImapListing::growBatch(100, 3),
"the batch doubles with the success streak");
$this->assertEqual(5, ImapListing::growBatch(5, 3),
"growth never exceeds the number still wanted");
$this->assertEqual(5, ImapListing::shrinkBatch(10),
"a failed batch is halved");
$this->assertEqual(1, ImapListing::shrinkBatch(3),
"halving rounds down");
$this->assertEqual(1, ImapListing::shrinkBatch(1),
"the batch never drops below one");
}
/**
* orderBySequence should return fetched messages in the requested
* order and quietly drop any sequence number that was never
* gathered, leaving no holes.
*/
public function orderBySequenceTestCase()
{
$by_seq = [3 => ['SEQ' => 3], 1 => ['SEQ' => 1]];
$this->assertEqual(
[['SEQ' => 1], ['SEQ' => 3]],
ImapListing::orderBySequence($by_seq, [1, 2, 3]),
"requested order kept; the missing seq 2 is dropped");
$this->assertEqual([],
ImapListing::orderBySequence([], [1, 2, 3]),
"nothing gathered yields an empty list");
}
/**
* placeholderRow should build a row shaped like a fetched message,
* marked as a placeholder, carrying the passed-in subject text and
* never doing a language lookup of its own.
*/
public function placeholderRowTestCase()
{
$row = ImapListing::placeholderRow(7, "Unreadable message");
$this->assertEqual(7, $row['SEQ'],
"the sequence number is carried so the row can be placed");
$this->assertEqual("Unreadable message", $row['SUBJECT'],
"the caller-supplied subject text is used as-is");
$this->assertTrue($row['IS_PLACEHOLDER'],
"the row is marked as a placeholder");
$this->assertEqual(0, $row['UID'],
"there is no real message, so the UID is zero");
}
}