/ tests / ImapListingTest.php
<?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");
    }
}
X