<?php
/** @license MIT
 * Copyright 2017 J. King, Dustin Wilson et al.
 * See LICENSE and AUTHORS files for details */

declare(strict_types=1);

namespace JKingWeb\Arsse\TestCase\Database;

use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Context\UnionContext;
use JKingWeb\Arsse\Context\RootContext;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo;

trait SeriesArticle {
    protected $fields;

    protected function setUpSeriesArticle(): void {
        $this->data = [
            'arsse_users' => [
                'columns' => [
                    'id'       => 'str',
                    'password' => 'str',
                    'num'      => 'int',
                ],
                'rows' => [
                    ["jane.doe@example.com", "", 1],
                    ["john.doe@example.com", "", 2],
                    ["john.doe@example.org", "", 3],
                    ["john.doe@example.net", "", 4],
                    ["jill.doe@example.com", "", 5],
                ],
            ],
            'arsse_feeds' => [
                'columns' => [
                    'id'         => "int",
                    'url'        => "str",
                    'title'      => "str",
                ],
                'rows' => [
                    [1,"http://example.com/1", "Feed 1"],
                    [2,"http://example.com/2", "Feed 2"],
                    [3,"http://example.com/3", "Feed 3"],
                    [4,"http://example.com/4", "Feed 4"],
                    [5,"http://example.com/5", "Feed 5"],
                    [6,"http://example.com/6", "Feed 6"],
                    [7,"http://example.com/7", "Feed 7"],
                    [8,"http://example.com/8", "Feed 8"],
                    [9,"http://example.com/9", "Feed 9"],
                    [10,"http://example.com/10", "Feed 10"],
                    [11,"http://example.com/11", "Feed 11"],
                    [12,"http://example.com/12", "Feed 12"],
                    [13,"http://example.com/13", "Feed 13"],
                ],
            ],
            'arsse_folders' => [
                'columns' => [
                    'id'     => "int",
                    'owner'  => "str",
                    'parent' => "int",
                    'name'   => "str",
                ],
                'rows' => [
                    [1, "john.doe@example.com", null, "Technology"],
                    [2, "john.doe@example.com",    1, "Software"],
                    [3, "john.doe@example.com",    1, "Rocketry"],
                    [4, "jane.doe@example.com", null, "Politics"],
                    [5, "john.doe@example.com", null, "Politics"],
                    [6, "john.doe@example.com",    2, "Politics"],
                    [7, "john.doe@example.net", null, "Technology"],
                    [8, "john.doe@example.net",    7, "Software"],
                    [9, "john.doe@example.net", null, "Politics"],
                ],
            ],
            'arsse_tags' => [
                'columns' => [
                    'id'    => "int",
                    'owner' => "str",
                    'name'  => "str",
                ],
                'rows' => [
                    [1, "john.doe@example.com", "Technology"],
                    [2, "john.doe@example.com", "Software"],
                    [3, "john.doe@example.com", "Rocketry"],
                    [4, "jane.doe@example.com", "Politics"],
                    [5, "john.doe@example.com", "Politics"],
                    [6, "john.doe@example.net", "Technology"],
                    [7, "john.doe@example.net", "Software"],
                    [8, "john.doe@example.net", "Politics"],
                ],
            ],
            'arsse_subscriptions' => [
                'columns' => [
                    'id'         => "int",
                    'owner'      => "str",
                    'feed'       => "int",
                    'folder'     => "int",
                    'title'      => "str",
                    'scrape'     => "bool",
                ],
                'rows' => [
                    [1, "john.doe@example.com",1, null,"Subscription 1",0],
                    [2, "john.doe@example.com",2, null,null,0],
                    [3, "john.doe@example.com",3,    1,"Subscription 3",0],
                    [4, "john.doe@example.com",4,    6,null,0],
                    [5, "john.doe@example.com",10,   5,"Subscription 5",0],
                    [6, "jane.doe@example.com",1, null,null,0],
                    [7, "jane.doe@example.com",10,null,"Subscription 7",0],
                    [8, "john.doe@example.org",11,null,null,0],
                    [9, "john.doe@example.org",12,null,"Subscription 9",0],
                    [10,"john.doe@example.org",13,null,null,0],
                    [11,"john.doe@example.net",10,null,"Subscription 11",0],
                    [12,"john.doe@example.net",2,    9,null,0],
                    [13,"john.doe@example.net",3,    8,"Subscription 13",0],
                    [14,"john.doe@example.net",4,    7,null,0],
                    [15,"jill.doe@example.com",11,null,null,1],
                ],
            ],
            'arsse_tag_members' => [
                'columns' => [
                    'tag'          => "int",
                    'subscription' => "int",
                    'assigned'     => "bool",
                ],
                'rows' => [
                    [1,3,1],
                    [1,4,1],
                    [2,4,1],
                    [5,1,0],
                    [5,4,1],
                    [5,5,1],
                    [6,13,1],
                    [6,14,1],
                    [7,13,1],
                    [8,12,1],
                ],
            ],
            'arsse_articles' => [
                'columns' => [
                    'id'                 => "int",
                    'feed'               => "int",
                    'url'                => "str",
                    'title'              => "str",
                    'author'             => "str",
                    'published'          => "datetime",
                    'edited'             => "datetime",
                    'content'            => "str",
                    'guid'               => "str",
                    'url_title_hash'     => "str",
                    'url_content_hash'   => "str",
                    'title_content_hash' => "str",
                    'modified'           => "datetime",
                    'content_scraped'    => "str",
                ],
                'rows' => [
                    [1,1,null,"Title one",  null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z",null],
                    [2,1,null,"Title two",  null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z",null],
                    [3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z",null],
                    [4,2,null,null,"John Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
                    [5,3,null,null,"John Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
                    [6,3,null,null,"Jane Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
                    [7,4,null,null,"Jane Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
                    [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
                    [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
                    [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
                    [11,6,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
                    [12,6,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
                    [13,7,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
                    [14,7,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
                    [15,8,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
                    [16,8,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
                    [17,9,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
                    [18,9,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
                    [19,10,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
                    [20,10,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
                    [101,11,'http://example.com/1','Article title 1','','2000-01-01 00:00:00','2000-01-01 00:00:01','<p>Article content 1</p>','e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda','f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6','fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4','18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207','2000-01-01 01:00:00',"<p>Scraped content 1</p>"],
                    [102,11,'http://example.com/2','Article title 2','','2000-01-02 00:00:00','2000-01-02 00:00:02','<p>Article content 2</p>','5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7','0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153','13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9','2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e','2000-01-02 02:00:00',null],
                    [103,12,'http://example.com/3','Article title 3','','2000-01-03 00:00:00','2000-01-03 00:00:03','<p>Article content 3</p>','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b','2000-01-03 03:00:00',null],
                    [104,12,'http://example.com/4','Article title 4','','2000-01-04 00:00:00','2000-01-04 00:00:04','<p>Article content 4</p>','804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8','f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3','ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9','2000-01-04 04:00:00',null],
                    [105,13,'http://example.com/5','Article title 5','','2000-01-05 00:00:00','2000-01-05 00:00:05','<p>Article content 5</p>','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41','d40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022','834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900','43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba','2000-01-05 05:00:00',null],
                ],
            ],
            'arsse_enclosures' => [
                'columns' => [
                    'article' => "int",
                    'url'     => "str",
                    'type'    => "str",
                ],
                'rows' => [
                    [102,"http://example.com/text","text/plain"],
                    [103,"http://example.com/video","video/webm"],
                    [104,"http://example.com/image","image/svg+xml"],
                    [105,"http://example.com/audio","audio/ogg"],

                ],
            ],
            'arsse_editions' => [
                'columns' => [
                    'id'       => "int",
                    'article'  => "int",
                ],
                'rows' => [
                    [1,1],
                    [2,2],
                    [3,3],
                    [4,4],
                    [5,5],
                    [6,6],
                    [7,7],
                    [8,8],
                    [9,9],
                    [10,10],
                    [11,11],
                    [12,12],
                    [13,13],
                    [14,14],
                    [15,15],
                    [16,16],
                    [17,17],
                    [18,18],
                    [19,19],
                    [20,20],
                    [101,101],
                    [102,102],
                    [103,103],
                    [104,104],
                    [105,105],
                    [202,102],
                    [203,103],
                    [204,104],
                    [205,105],
                    [305,105],
                    [1001,20],
                ],
            ],
            'arsse_marks' => [
                'columns' => [
                    'subscription' => "int",
                    'article'      => "int",
                    'read'         => "bool",
                    'starred'      => "bool",
                    'modified'     => "datetime",
                    'note'         => "str",
                    'hidden'       => "bool",
                ],
                'rows' => [
                    [1,   1,1,1,'2000-01-01 00:00:00','',0],
                    [5,  19,1,0,'2016-01-01 00:00:00','',0],
                    [5,  20,0,1,'2005-01-01 00:00:00','',0],
                    [7,  20,1,0,'2010-01-01 00:00:00','',0],
                    [8, 102,1,0,'2000-01-02 02:00:00','Note 2',0],
                    [9, 103,0,1,'2000-01-03 03:00:00','Note 3',0],
                    [9, 104,1,1,'2000-01-04 04:00:00','Note 4',0],
                    [10,105,0,0,'2000-01-05 05:00:00','',0],
                    [11, 19,0,0,'2017-01-01 00:00:00','ook',0],
                    [11, 20,1,0,'2017-01-01 00:00:00','eek',0],
                    [12,  3,0,1,'2017-01-01 00:00:00','ack',0],
                    [12,  4,1,1,'2017-01-01 00:00:00','ach',0],
                    [1,   2,0,0,'2010-01-01 00:00:00','Some Note',0],
                    [3,   6,0,0,'2000-01-01 00:00:00','',1],
                    [6,   1,0,1,'2010-01-01 00:00:00','',1],
                    [6,   2,1,0,'2010-01-01 00:00:00','',1],
                ],
            ],
            'arsse_categories' => [ // author-supplied categories
                'columns' => [
                    'article'  => "int",
                    'name'     => "str",
                ],
                'rows' => [
                    [19,"Fascinating"],
                    [19,"Logical"],
                    [20,"Interesting"],
                    [20,"Logical"],
                ],
            ],
            'arsse_labels' => [
                'columns' => [
                    'id'       => "int",
                    'owner'    => "str",
                    'name'     => "str",
                ],
                'rows' => [
                    [1,"john.doe@example.com","Interesting"],
                    [2,"john.doe@example.com","Fascinating"],
                    [3,"jane.doe@example.com","Boring"],
                    [4,"john.doe@example.com","Lonely"],
                ],
            ],
            'arsse_label_members' => [
                'columns' => [
                    'label'        => "int",
                    'article'      => "int",
                    'subscription' => "int",
                    'assigned'     => "bool",
                    'modified'     => "datetime",
                ],
                'rows' => [
                    [1, 1,1,1,'2000-01-01 00:00:00'],
                    [2, 1,1,1,'2000-01-01 00:00:00'],
                    [1,19,5,1,'2000-01-01 00:00:00'],
                    [2,20,5,1,'2000-01-01 00:00:00'],
                    [1, 5,3,0,'2000-01-01 00:00:00'],
                    [2, 5,3,1,'2000-01-01 00:00:00'],
                    [4, 7,4,0,'2000-01-01 00:00:00'],
                    [4, 8,4,1,'2015-01-01 00:00:00'],
                ],
            ],
        ];
        $this->matches = [
            [
                'id'                 => 101,
                'url'                => 'http://example.com/1',
                'title'              => 'Article title 1',
                'subscription_title' => "Feed 11",
                'author'             => '',
                'content'            => '<p>Article content 1</p>',
                'guid'               => 'e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda',
                'published_date'     => '2000-01-01 00:00:00',
                'edited_date'        => '2000-01-01 00:00:01',
                'modified_date'      => '2000-01-01 01:00:00',
                'unread'             => 1,
                'starred'            => 0,
                'edition'            => 101,
                'subscription'       => 8,
                'fingerprint'        => 'f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6:fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4:18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207',
                'media_url'          => null,
                'media_type'         => null,
                'note'               => "",
            ],
            [
                'id'                 => 102,
                'url'                => 'http://example.com/2',
                'title'              => 'Article title 2',
                'subscription_title' => "Feed 11",
                'author'             => '',
                'content'            => '<p>Article content 2</p>',
                'guid'               => '5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7',
                'published_date'     => '2000-01-02 00:00:00',
                'edited_date'        => '2000-01-02 00:00:02',
                'modified_date'      => '2000-01-02 02:00:00',
                'unread'             => 0,
                'starred'            => 0,
                'edition'            => 202,
                'subscription'       => 8,
                'fingerprint'        => '0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153:13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9:2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e',
                'media_url'          => "http://example.com/text",
                'media_type'         => "text/plain",
                'note'               => "Note 2",
            ],
            [
                'id'                 => 103,
                'url'                => 'http://example.com/3',
                'title'              => 'Article title 3',
                'subscription_title' => "Subscription 9",
                'author'             => '',
                'content'            => '<p>Article content 3</p>',
                'guid'               => '31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92',
                'published_date'     => '2000-01-03 00:00:00',
                'edited_date'        => '2000-01-03 00:00:03',
                'modified_date'      => '2000-01-03 03:00:00',
                'unread'             => 1,
                'starred'            => 1,
                'edition'            => 203,
                'subscription'       => 9,
                'fingerprint'        => 'f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b:b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406:ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b',
                'media_url'          => "http://example.com/video",
                'media_type'         => "video/webm",
                'note'               => "Note 3",
            ],
            [
                'id'                 => 104,
                'url'                => 'http://example.com/4',
                'title'              => 'Article title 4',
                'subscription_title' => "Subscription 9",
                'author'             => '',
                'content'            => '<p>Article content 4</p>',
                'guid'               => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180',
                'published_date'     => '2000-01-04 00:00:00',
                'edited_date'        => '2000-01-04 00:00:04',
                'modified_date'      => '2000-01-04 04:00:00',
                'unread'             => 0,
                'starred'            => 1,
                'edition'            => 204,
                'subscription'       => 9,
                'fingerprint'        => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8:f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3:ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9',
                'media_url'          => "http://example.com/image",
                'media_type'         => "image/svg+xml",
                'note'               => "Note 4",
            ],
            [
                'id'                 => 105,
                'url'                => 'http://example.com/5',
                'title'              => 'Article title 5',
                'subscription_title' => "Feed 13",
                'author'             => '',
                'content'            => '<p>Article content 5</p>',
                'guid'               => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41',
                'published_date'     => '2000-01-05 00:00:00',
                'edited_date'        => '2000-01-05 00:00:05',
                'modified_date'      => '2000-01-05 05:00:00',
                'unread'             => 1,
                'starred'            => 0,
                'edition'            => 305,
                'subscription'       => 10,
                'fingerprint'        => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022:834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900:43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba',
                'media_url'          => "http://example.com/audio",
                'media_type'         => "audio/ogg",
                'note'               => "",
            ],
        ];
        $this->fields = [
            "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "hidden", "edition", "edited_date",
            "url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint",
            "folder", "top_folder", "folder_name", "top_folder_name",
            "content", "media_url", "media_type",
            "note",
        ];
        $this->checkTables = ['arsse_marks' => ["subscription", "article", "read", "starred", "modified", "note", "hidden"]];
        $this->user = "john.doe@example.net";
    }

    protected function tearDownSeriesArticle(): void {
        unset($this->data, $this->matches, $this->fields, $this->checkTables, $this->user);
    }

    /** @dataProvider provideContextMatches */
    public function testListArticlesCheckingContext(RootContext $c, array $exp): void {
        $ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c, ["id"], ["id"])->getAll(), "id");
        sort($ids);
        sort($exp);
        $this->assertEquals($exp, $ids);
    }

    public function provideContextMatches(): iterable {
        $setSize = (new \ReflectionClassConstant(Database::class, "LIMIT_SET_SIZE"))->getValue();
        return [
            'Blank context'                                              => [new Context, [1,2,3,4,5,6,7,8,19,20]],
            'Folder tree'                                                => [(new Context)->folder(1), [5,6,7,8]],
            'Entire folder tree'                                         => [(new Context)->folder(0), [1,2,3,4,5,6,7,8,19,20]],
            'Leaf folder'                                                => [(new Context)->folder(6), [7,8]],
            'Multiple folder trees'                                      => [(new Context)->folders([1,5]), [5,6,7,8,19,20]],
            'Multiple folder trees including root'                       => [(new Context)->folders([0,1,5]), [1,2,3,4,5,6,7,8,19,20]],
            'Shallow folder'                                             => [(new Context)->folderShallow(1), [5,6]],
            'Root folder only'                                           => [(new Context)->folderShallow(0), [1,2,3,4]],
            'Multiple shallow folders'                                   => [(new Context)->foldersShallow([1,6]), [5,6,7,8]],
            'Subscription'                                               => [(new Context)->subscription(5), [19,20]],
            'Multiple subscriptions'                                     => [(new Context)->subscriptions([4,5]), [7,8,19,20]],
            'Unread'                                                     => [(new Context)->subscription(5)->unread(true), [20]],
            'Read'                                                       => [(new Context)->subscription(5)->unread(false), [19]],
            'Starred'                                                    => [(new Context)->starred(true), [1,20]],
            'Unstarred'                                                  => [(new Context)->starred(false), [2,3,4,5,6,7,8,19]],
            'Starred and Read'                                           => [(new Context)->starred(true)->unread(false), [1]],
            'Starred and Read in subscription'                           => [(new Context)->starred(true)->unread(false)->subscription(5), []],
            'Annotated'                                                  => [(new Context)->annotated(true), [2]],
            'Not annotated'                                              => [(new Context)->annotated(false), [1,3,4,5,6,7,8,19,20]],
            'Hidden'                                                     => [(new Context)->hidden(true), [6]],
            'Not hidden'                                                 => [(new Context)->hidden(false), [1,2,3,4,5,7,8,19,20]],
            'Labelled'                                                   => [(new Context)->labelled(true), [1,5,8,19,20]],
            'Not labelled'                                               => [(new Context)->labelled(false), [2,3,4,6,7]],
            'Not after edition 999'                                      => [(new Context)->subscription(5)->editionRange(null, 999), [19]],
            'Not after edition 19'                                       => [(new Context)->subscription(5)->editionRange(null, 19), [19]],
            'Not before edition 999'                                     => [(new Context)->subscription(5)->editionRange(999, null), [20]],
            'Not before edition 1001'                                    => [(new Context)->subscription(5)->editionRange(1001, null), [20]],
            'Not after article 3'                                        => [(new Context)->articleRange(null, 3), [1,2,3]],
            'Not before article 19'                                      => [(new Context)->articleRange(19, null), [19,20]],
            'Modified by author since 2005'                              => [(new Context)->modifiedRange("2005-01-01T00:00:00Z", null), [2,4,6,8,20]],
            'Modified by author since 2010'                              => [(new Context)->modifiedRange("2010-01-01T00:00:00Z", null), [2,4,6,8,20]],
            'Not modified by author since 2005'                          => [(new Context)->modifiedRange(null, "2005-01-01T00:00:00Z"), [1,3,5,7,19]],
            'Not modified by author since 2000'                          => [(new Context)->modifiedRange(null, "2000-01-01T00:00:00Z"), [1,3,5,7,19]],
            'Marked or labelled since 2014'                              => [(new Context)->markedRange("2014-01-01T00:00:00Z", null), [8,19]],
            'Marked or labelled since 2010'                              => [(new Context)->markedRange("2010-01-01T00:00:00Z", null), [2,4,6,8,19,20]],
            'Not marked or labelled since 2014'                          => [(new Context)->markedRange(null, "2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]],
            'Not marked or labelled since 2005'                          => [(new Context)->markedRange(null, "2005-01-01T00:00:00Z"), [1,3,5,7]],
            'Marked or labelled between 2000 and 2015'                   => [(new Context)->markedRange("2000-01-01T00:00:00Z", "2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]],
            'Marked or labelled in 2010'                                 => [(new Context)->markedRange("2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"), [2,4,6,20]],
            'Paged results'                                              => [(new Context)->limit(2)->editionRange(4, null), [4,5]],
            'With label ID 1'                                            => [(new Context)->label(1), [1,19]],
            'With label ID 2'                                            => [(new Context)->label(2), [1,5,20]],
            'With label ID 1 or 2'                                       => [(new Context)->labels([1,2]), [1,5,19,20]],
            'With label "Interesting"'                                   => [(new Context)->labelName("Interesting"), [1,19]],
            'With label "Fascinating"'                                   => [(new Context)->labelName("Fascinating"), [1,5,20]],
            'With label "Interesting" or "Fascinating"'                  => [(new Context)->labelNames(["Interesting","Fascinating"]), [1,5,19,20]],
            'Article ID 20'                                              => [(new Context)->article(20), [20]],
            'Edition ID 1001'                                            => [(new Context)->edition(1001), [20]],
            'Multiple articles'                                          => [(new Context)->articles([1,20,50]), [1,20]],
            'Multiple starred articles'                                  => [(new Context)->articles([1,2,3])->starred(true), [1]],
            'Multiple unstarred articles'                                => [(new Context)->articles([1,2,3])->starred(false), [2,3]],
            'Multiple articles'                                          => [(new Context)->articles([1,20,50]), [1,20]],
            'Multiple editions'                                          => [(new Context)->editions([1,1001,50]), [1,20]],
            '150 articles'                                               => [(new Context)->articles(range(1, $setSize * 3)), [1,2,3,4,5,6,7,8,19,20]],
            'Search title or content 1'                                  => [(new Context)->searchTerms(["Article"]), [1,2,3]],
            'Search title or content 2'                                  => [(new Context)->searchTerms(["one", "first"]), [1]],
            'Search title or content 3'                                  => [(new Context)->searchTerms(["one first"]), []],
            'Search title 1'                                             => [(new Context)->titleTerms(["two"]), [2]],
            'Search title 2'                                             => [(new Context)->titleTerms(["title two"]), [2]],
            'Search title 3'                                             => [(new Context)->titleTerms(["two", "title"]), [2]],
            'Search title 4'                                             => [(new Context)->titleTerms(["two title"]), []],
            'Search note 1'                                              => [(new Context)->annotationTerms(["some"]), [2]],
            'Search note 2'                                              => [(new Context)->annotationTerms(["some Note"]), [2]],
            'Search note 3'                                              => [(new Context)->annotationTerms(["note", "some"]), [2]],
            'Search note 4'                                              => [(new Context)->annotationTerms(["some", "sauce"]), []],
            'Search author 1'                                            => [(new Context)->authorTerms(["doe"]), [4,5,6,7]],
            'Search author 2'                                            => [(new Context)->authorTerms(["jane doe"]), [6,7]],
            'Search author 3'                                            => [(new Context)->authorTerms(["doe", "jane"]), [6,7]],
            'Search author 4'                                            => [(new Context)->authorTerms(["doe jane"]), []],
            'Folder tree 1 excluding subscription 4'                     => [(new Context)->not->subscription(4)->folder(1), [5,6]],
            'Folder tree 1 excluding articles 7 and 8'                   => [(new Context)->folder(1)->not->articles([7,8]), [5,6]],
            'Folder tree 1 excluding no articles'                        => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]],
            'Folder tree 1 excluding no labels'                          => [(new Context)->folder(1)->not->labels([]), [5,6,7,8]],
            'Folder tree 1 excluding no tags'                            => [(new Context)->folder(1)->not->tags([]), [5,6,7,8]],
            'Marked or labelled between 2000 and 2015 excluding in 2010' => [(new Context)->markedRange("2000-01-01T00:00:00Z", "2015-12-31T23:59:59")->not->markedRange("2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"), [1,3,5,7,8]],
            'Search with exclusion'                                      => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]],
            'Excluded folder tree'                                       => [(new Context)->not->folder(1), [1,2,3,4,19,20]],
            'Excluding label ID 2'                                       => [(new Context)->not->label(2), [2,3,4,6,7,8,19]],
            'Excluding label "Fascinating"'                              => [(new Context)->not->labelName("Fascinating"), [2,3,4,6,7,8,19]],
            'Search 501 terms'                                           => [(new Context)->searchTerms(array_merge(range(1, 500), [str_repeat("a", 1000)])), []],
            'With tag ID 1'                                              => [(new Context)->tag(1), [5,6,7,8]],
            'With tag ID 5'                                              => [(new Context)->tag(5), [7,8,19,20]],
            'With tag ID 1 or 5'                                         => [(new Context)->tags([1,5]), [5,6,7,8,19,20]],
            'With tag "Technology"'                                      => [(new Context)->tagName("Technology"), [5,6,7,8]],
            'With tag "Politics"'                                        => [(new Context)->tagName("Politics"), [7,8,19,20]],
            'With tag "Technology" or "Politics"'                        => [(new Context)->tagNames(["Technology","Politics"]), [5,6,7,8,19,20]],
            'Excluding tag ID 1'                                         => [(new Context)->not->tag(1), [1,2,3,4,19,20]],
            'Excluding tag ID 5'                                         => [(new Context)->not->tag(5), [1,2,3,4,5,6]],
            'Excluding tag "Technology"'                                 => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]],
            'Excluding tag "Politics"'                                   => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]],
            'Excluding tags ID 1 and 5'                                  => [(new Context)->not->tags([1,5]), [1,2,3,4]],
            'Excluding tags "Technology" and "Politics"'                 => [(new Context)->not->tagNames(["Technology","Politics"]), [1,2,3,4]],
            'Excluding entire folder tree'                               => [(new Context)->not->folder(0), []],
            'Excluding multiple folder trees'                            => [(new Context)->not->folders([1,5]), [1,2,3,4]],
            'Excluding multiple folder trees including root'             => [(new Context)->not->folders([0,1,5]), []],
            'Before article 3'                                           => [(new Context)->not->articleRange(3, null), [1,2]],
            'Before article 19'                                          => [(new Context)->not->articleRange(null, 19), [20]],
            'Marked or labelled in 2010 or 2015'                         => [(new Context)->markedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [2,4,6,8,20]],
            'Not marked or labelled in 2010 or 2015'                     => [(new Context)->not->markedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [1,3,5,7,19]],
            'Marked or labelled prior to 2010 or since 2015'             => [(new Context)->markedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [1,3,5,7,8,19]],
            'Not marked or labelled prior to 2010 or since 2015'         => [(new Context)->not->markedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [2,4,6,20]],
            'Modified in 2010 or 2015'                                   => [(new Context)->modifiedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [2,4,6,8,20]],
            'Not modified in 2010 or 2015'                               => [(new Context)->not->modifiedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [1,3,5,7,19]],
            'Modified prior to 2010 or since 2015'                       => [(new Context)->modifiedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [1,3,5,7,19]],
            'Not modified prior to 2010 or since 2015'                   => [(new Context)->not->modifiedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [2,4,6,8,20]],
            'Either read or hidden'                                      => [(new UnionContext((new Context)->unread(false), (new Context)->hidden(true))), [1, 6, 19]],
        ];
    }

    public function testRetrieveArticleIdsForEditions(): void {
        $exp = [
            1    => 1,
            2    => 2,
            3    => 3,
            4    => 4,
            5    => 5,
            6    => 6,
            7    => 7,
            8    => 8,
            9    => 9,
            10   => 10,
            11   => 11,
            12   => 12,
            13   => 13,
            14   => 14,
            15   => 15,
            16   => 16,
            17   => 17,
            18   => 18,
            19   => 19,
            20   => 20,
            101  => 101,
            102  => 102,
            103  => 103,
            104  => 104,
            105  => 105,
            202  => 102,
            203  => 103,
            204  => 104,
            205  => 105,
            305  => 105,
            1001 => 20,
        ];
        $this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001)));
    }

    public function testListArticlesOfAMissingFolder(): void {
        $this->assertException("idMissing", "Db", "ExceptionInput");
        Arsse::$db->articleList($this->user, (new Context)->folder(1));
    }

    public function testListArticlesOfAMissingSubscription(): void {
        $this->assertException("idMissing", "Db", "ExceptionInput");
        Arsse::$db->articleList($this->user, (new Context)->subscription(1));
    }

    public function testListArticlesCheckingProperties(): void {
        $this->user = "john.doe@example.org";
        // check that the different fieldset groups return the expected columns
        foreach ($this->fields as $column) {
            $test = array_keys(Arsse::$db->articleList($this->user, (new Context)->article(101), [$column])->getRow());
            $this->assertEquals([$column], $test);
        }
        // check that an unknown field is silently ignored
        $columns = array_merge($this->fields, ["unknown_column", "bogus_column"]);
        $test = array_keys(Arsse::$db->articleList($this->user, (new Context)->article(101), $columns)->getRow());
        $this->assertEquals($this->fields, $test);
    }

    /** @dataProvider provideOrderedLists */
    public function testListArticlesCheckingOrder(array $sortCols, array $exp): void {
        $act = ValueInfo::normalize(array_column(iterator_to_array(Arsse::$db->articleList("john.doe@example.com", null, ["id"], $sortCols)), "id"), ValueInfo::T_INT | ValueInfo::M_ARRAY);
        $this->assertSame($exp, $act);
    }

    public function provideOrderedLists(): iterable {
        return [
            [["id"], [1,2,3,4,5,6,7,8,19,20]],
            [["id asc"], [1,2,3,4,5,6,7,8,19,20]],
            [["id desc"], [20,19,8,7,6,5,4,3,2,1]],
            [["edition"], [1,2,3,4,5,6,7,8,19,20]],
            [["edition asc"], [1,2,3,4,5,6,7,8,19,20]],
            [["edition desc"], [20,19,8,7,6,5,4,3,2,1]],
            [["id", "edition desk"], [1,2,3,4,5,6,7,8,19,20]],
            [["id", "editio"], [1,2,3,4,5,6,7,8,19,20]],
        ];
    }

    public function testMarkNothing(): void {
        $this->assertSame(0, Arsse::$db->articleMark($this->user, []));
    }

    public function testMarkAllArticlesUnread(): void {
        Arsse::$db->articleMark($this->user, ['read' => false]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][9][2] = 0;
        $state['arsse_marks']['rows'][9][4] = $now;
        $state['arsse_marks']['rows'][11][2] = 0;
        $state['arsse_marks']['rows'][11][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesRead(): void {
        Arsse::$db->articleMark($this->user, ['read' => true]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][8][2] = 1;
        $state['arsse_marks']['rows'][8][4] = $now;
        $state['arsse_marks']['rows'][10][2] = 1;
        $state['arsse_marks']['rows'][10][4] = $now;
        $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'',0];
        $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesUnstarred(): void {
        Arsse::$db->articleMark($this->user, ['starred' => false]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][10][3] = 0;
        $state['arsse_marks']['rows'][10][4] = $now;
        $state['arsse_marks']['rows'][11][3] = 0;
        $state['arsse_marks']['rows'][11][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesStarred(): void {
        Arsse::$db->articleMark($this->user, ['starred' => true]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][8][3] = 1;
        $state['arsse_marks']['rows'][8][4] = $now;
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesUnreadAndUnstarred(): void {
        Arsse::$db->articleMark($this->user, ['read' => false,'starred' => false]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][9][2] = 0;
        $state['arsse_marks']['rows'][9][4] = $now;
        $state['arsse_marks']['rows'][10][3] = 0;
        $state['arsse_marks']['rows'][10][4] = $now;
        $state['arsse_marks']['rows'][11][2] = 0;
        $state['arsse_marks']['rows'][11][3] = 0;
        $state['arsse_marks']['rows'][11][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesReadAndStarred(): void {
        Arsse::$db->articleMark($this->user, ['read' => true,'starred' => true]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][8][2] = 1;
        $state['arsse_marks']['rows'][8][3] = 1;
        $state['arsse_marks']['rows'][8][4] = $now;
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $state['arsse_marks']['rows'][10][2] = 1;
        $state['arsse_marks']['rows'][10][4] = $now;
        $state['arsse_marks']['rows'][] = [13,5,1,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [13,6,1,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,7,1,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,8,1,1,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesUnreadAndStarred(): void {
        Arsse::$db->articleMark($this->user, ['read' => false,'starred' => true]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][8][3] = 1;
        $state['arsse_marks']['rows'][8][4] = $now;
        $state['arsse_marks']['rows'][9][2] = 0;
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $state['arsse_marks']['rows'][11][2] = 0;
        $state['arsse_marks']['rows'][11][4] = $now;
        $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesReadAndUnstarred(): void {
        Arsse::$db->articleMark($this->user, ['read' => true,'starred' => false]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][8][2] = 1;
        $state['arsse_marks']['rows'][8][4] = $now;
        $state['arsse_marks']['rows'][10][2] = 1;
        $state['arsse_marks']['rows'][10][3] = 0;
        $state['arsse_marks']['rows'][10][4] = $now;
        $state['arsse_marks']['rows'][11][3] = 0;
        $state['arsse_marks']['rows'][11][4] = $now;
        $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'',0];
        $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testSetNoteForAllArticles(): void {
        Arsse::$db->articleMark($this->user, ['note' => "New note"]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][8][5] = "New note";
        $state['arsse_marks']['rows'][8][4] = $now;
        $state['arsse_marks']['rows'][9][5] = "New note";
        $state['arsse_marks']['rows'][9][4] = $now;
        $state['arsse_marks']['rows'][10][5] = "New note";
        $state['arsse_marks']['rows'][10][4] = $now;
        $state['arsse_marks']['rows'][11][5] = "New note";
        $state['arsse_marks']['rows'][11][4] = $now;
        $state['arsse_marks']['rows'][] = [13,5,0,0,$now,'New note',0];
        $state['arsse_marks']['rows'][] = [13,6,0,0,$now,'New note',0];
        $state['arsse_marks']['rows'][] = [14,7,0,0,$now,'New note',0];
        $state['arsse_marks']['rows'][] = [14,8,0,0,$now,'New note',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkATreeFolder(): void {
        Arsse::$db->articleMark($this->user, ['read' => true], (new Context)->folder(7));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'',0];
        $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkALeafFolder(): void {
        Arsse::$db->articleMark($this->user, ['read' => true], (new Context)->folder(8));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'',0];
        $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAMissingFolder(): void {
        $this->assertException("idMissing", "Db", "ExceptionInput");
        Arsse::$db->articleMark($this->user, ['read' => true], (new Context)->folder(42));
    }

    public function testMarkASubscription(): void {
        Arsse::$db->articleMark($this->user, ['read' => true], (new Context)->subscription(13));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][] = [13,5,1,0,$now,'',0];
        $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAMissingSubscription(): void {
        $this->assertException("idMissing", "Db", "ExceptionInput");
        Arsse::$db->articleMark($this->user, ['read' => true], (new Context)->folder(2112));
    }

    public function testMarkAnArticle(): void {
        Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->article(20));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkMultipleArticles(): void {
        Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->articles([2,4,7,20]));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkMultipleArticlessUnreadAndStarred(): void {
        Arsse::$db->articleMark($this->user, ['read' => false,'starred' => true], (new Context)->articles([2,4,7,20]));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][9][2] = 0;
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $state['arsse_marks']['rows'][11][2] = 0;
        $state['arsse_marks']['rows'][11][4] = $now;
        $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkTooManyMultipleArticles(): void {
        $setSize = (new \ReflectionClassConstant(Database::class, "LIMIT_SET_SIZE"))->getValue();
        $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read' => false,'starred' => true], (new Context)->articles(range(1, $setSize * 3))));
    }

    public function testMarkAMissingArticle(): void {
        $this->assertException("subjectMissing", "Db", "ExceptionInput");
        Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->article(1));
    }

    public function testMarkAnEdition(): void {
        Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->edition(1001));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkMultipleEditions(): void {
        Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->editions([2,4,7,20]));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkMultipleMissingEditions(): void {
        $this->assertSame(0, Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->editions([500,501])));
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkMultipleEditionsUnread(): void {
        Arsse::$db->articleMark($this->user, ['read' => false], (new Context)->editions([2,4,7,1001]));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][9][2] = 0;
        $state['arsse_marks']['rows'][9][4] = $now;
        $state['arsse_marks']['rows'][11][2] = 0;
        $state['arsse_marks']['rows'][11][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkMultipleEditionsUnreadWithStale(): void {
        Arsse::$db->articleMark($this->user, ['read' => false], (new Context)->editions([2,4,7,20]));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][11][2] = 0;
        $state['arsse_marks']['rows'][11][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkMultipleEditionsUnreadAndStarredWithStale(): void {
        Arsse::$db->articleMark($this->user, ['read' => false,'starred' => true], (new Context)->editions([2,4,7,20]));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $state['arsse_marks']['rows'][11][2] = 0;
        $state['arsse_marks']['rows'][11][4] = $now;
        $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkTooManyMultipleEditions(): void {
        $this->assertSame(7, Arsse::$db->articleMark($this->user, ['read' => false,'starred' => true], (new Context)->editions(range(1, 51))));
    }

    public function testMarkAStaleEditionUnread(): void {
        Arsse::$db->articleMark($this->user, ['read' => false], (new Context)->edition(20)); // no changes occur
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAStaleEditionStarred(): void {
        Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->edition(20));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAStaleEditionUnreadAndStarred(): void {
        Arsse::$db->articleMark($this->user, ['read' => false,'starred' => true], (new Context)->edition(20)); // only starred is changed
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAStaleEditionUnreadAndUnstarred(): void {
        Arsse::$db->articleMark($this->user, ['read' => false,'starred' => false], (new Context)->edition(20)); // no changes occur
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAMissingEdition(): void {
        $this->assertException("subjectMissing", "Db", "ExceptionInput");
        Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->edition(2));
    }

    public function testMarkByOldestEdition(): void {
        Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->editionRange(19, null));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][8][3] = 1;
        $state['arsse_marks']['rows'][8][4] = $now;
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkByLatestEdition(): void {
        Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->editionRange(null, 20));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][8][3] = 1;
        $state['arsse_marks']['rows'][8][4] = $now;
        $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkByLastMarked(): void {
        Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->markedRange('2017-01-01T00:00:00Z', null));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][8][3] = 1;
        $state['arsse_marks']['rows'][8][4] = $now;
        $state['arsse_marks']['rows'][9][3] = 1;
        $state['arsse_marks']['rows'][9][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkByNotLastMarked(): void {
        Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->markedRange(null, '2000-01-01T00:00:00Z'));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][] = [13,5,0,1,$now,'',0];
        $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testCountArticles(): void {
        $setSize = (new \ReflectionClassConstant(Database::class, "LIMIT_SET_SIZE"))->getValue();
        $this->assertSame(2, Arsse::$db->articleCount("john.doe@example.com", (new Context)->starred(true)));
        $this->assertSame(4, Arsse::$db->articleCount("john.doe@example.com", (new Context)->folder(1)));
        $this->assertSame(1, Arsse::$db->articleCount("jane.doe@example.com", (new Context)->starred(true)));
        $this->assertSame(10, Arsse::$db->articleCount("john.doe@example.com", (new Context)->articles(range(1, $setSize * 3))));
    }

    public function testFetchStarredCounts(): void {
        $exp1 = ['total' => 2, 'unread' => 1, 'read' => 1];
        $exp2 = ['total' => 0, 'unread' => 0, 'read' => 0];
        $this->assertEquals($exp1, Arsse::$db->articleStarred("john.doe@example.com"));
        $this->assertEquals($exp2, Arsse::$db->articleStarred("jane.doe@example.com"));
    }

    public function testFetchLatestEdition(): void {
        $this->assertSame(1001, Arsse::$db->editionLatest($this->user));
        $this->assertSame(4, Arsse::$db->editionLatest($this->user, (new Context)->subscription(12)));
        $this->assertSame(5, Arsse::$db->editionLatest("john.doe@example.com", (new Context)->subscription(3)->hidden(false)));
    }

    public function testFetchLatestEditionOfMissingSubscription(): void {
        $this->assertException("idMissing", "Db", "ExceptionInput");
        Arsse::$db->editionLatest($this->user, (new Context)->subscription(1));
    }

    public function testListTheLabelsOfAnArticle(): void {
        $this->assertEquals([1,2], Arsse::$db->articleLabelsGet("john.doe@example.com", 1));
        $this->assertEquals([2], Arsse::$db->articleLabelsGet("john.doe@example.com", 5));
        $this->assertEquals([], Arsse::$db->articleLabelsGet("john.doe@example.com", 2));
        $this->assertEquals(["Fascinating","Interesting"], Arsse::$db->articleLabelsGet("john.doe@example.com", 1, true));
        $this->assertEquals(["Fascinating"], Arsse::$db->articleLabelsGet("john.doe@example.com", 5, true));
        $this->assertEquals([], Arsse::$db->articleLabelsGet("john.doe@example.com", 2, true));
    }

    public function testListTheLabelsOfAMissingArticle(): void {
        $this->assertException("subjectMissing", "Db", "ExceptionInput");
        Arsse::$db->articleLabelsGet($this->user, 101);
    }

    public function testListTheCategoriesOfAnArticle(): void {
        $exp = ["Fascinating", "Logical"];
        $this->assertSame($exp, Arsse::$db->articleCategoriesGet($this->user, 19));
        $exp = ["Interesting", "Logical"];
        $this->assertSame($exp, Arsse::$db->articleCategoriesGet($this->user, 20));
        $exp = [];
        $this->assertSame($exp, Arsse::$db->articleCategoriesGet($this->user, 4));
    }

    public function testListTheCategoriesOfAMissingArticle(): void {
        $this->assertException("subjectMissing", "Db", "ExceptionInput");
        Arsse::$db->articleCategoriesGet($this->user, 101);
    }

    /** @dataProvider provideArrayContextOptions */
    public function testUseTooFewValuesInArrayContext(string $option): void {
        $this->assertException("tooShort", "Db", "ExceptionInput");
        Arsse::$db->articleList($this->user, (new Context)->$option([]));
    }

    public function provideArrayContextOptions(): iterable {
        foreach ([
            "articles", "editions",
            "subscriptions", "foldersShallow", "folders",
            "tags", "tagNames", "labels", "labelNames",
            "searchTerms", "authorTerms", "annotationTerms",
            "modifiedRanges", "markedRanges",
        ] as $method) {
            yield [$method];
        }
    }

    public function testMarkAllArticlesNotHidden(): void {
        Arsse::$db->articleMark("jane.doe@example.com", ['hidden' => false]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][14][6] = 0;
        $state['arsse_marks']['rows'][14][4] = $now;
        $state['arsse_marks']['rows'][15][6] = 0;
        $state['arsse_marks']['rows'][15][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesHidden(): void {
        Arsse::$db->articleMark("jane.doe@example.com", ['hidden' => true]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][3][6] = 1;
        $state['arsse_marks']['rows'][3][4] = $now;
        $state['arsse_marks']['rows'][] = [7,19,0,0,$now,'',1];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesUnreadAndNotHidden(): void {
        Arsse::$db->articleMark("jane.doe@example.com", ['read' => false, 'hidden' => false]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][3][2] = 0;
        $state['arsse_marks']['rows'][3][4] = $now;
        $state['arsse_marks']['rows'][14][6] = 0;
        $state['arsse_marks']['rows'][14][4] = $now;
        $state['arsse_marks']['rows'][15][2] = 0;
        $state['arsse_marks']['rows'][15][6] = 0;
        $state['arsse_marks']['rows'][15][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesReadAndHidden(): void {
        Arsse::$db->articleMark("jane.doe@example.com", ['read' => true, 'hidden' => true]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][3][6] = 1;
        $state['arsse_marks']['rows'][3][4] = $now;
        $state['arsse_marks']['rows'][14][2] = 1;
        $state['arsse_marks']['rows'][14][4] = $now;
        $state['arsse_marks']['rows'][] = [7,19,1,0,$now,'',1];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesUnreadAndHidden(): void {
        Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => true]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][3][2] = 0;
        $state['arsse_marks']['rows'][3][6] = 1;
        $state['arsse_marks']['rows'][3][4] = $now;
        $state['arsse_marks']['rows'][15][2] = 0;
        $state['arsse_marks']['rows'][15][4] = $now;
        $state['arsse_marks']['rows'][] = [7,19,0,0,$now,'',1];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAllArticlesReadAndNotHidden(): void {
        Arsse::$db->articleMark("jane.doe@example.com", ['read' => true,'hidden' => false]);
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][14][2] = 1;
        $state['arsse_marks']['rows'][14][6] = 0;
        $state['arsse_marks']['rows'][14][4] = $now;
        $state['arsse_marks']['rows'][15][6] = 0;
        $state['arsse_marks']['rows'][15][4] = $now;
        $state['arsse_marks']['rows'][] = [7,19,1,0,$now,'',0];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkMultipleEditionsUnreadAndHiddenWithStale(): void {
        Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => true], (new Context)->editions([1,2,19,20]));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][3][6] = 1;
        $state['arsse_marks']['rows'][3][4] = $now;
        $state['arsse_marks']['rows'][15][2] = 0;
        $state['arsse_marks']['rows'][15][6] = 1;
        $state['arsse_marks']['rows'][15][4] = $now;
        $state['arsse_marks']['rows'][] = [7,19,0,0,$now,'',1];
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAStaleEditionHidden(): void {
        Arsse::$db->articleMark("jane.doe@example.com", ['hidden' => true], (new Context)->edition(20));
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][3][6] = 1;
        $state['arsse_marks']['rows'][3][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAStaleEditionUnreadAndHidden(): void {
        Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => true], (new Context)->edition(20)); // only starred is changed
        $now = Date::transform(time(), "sql");
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $state['arsse_marks']['rows'][3][6] = 1;
        $state['arsse_marks']['rows'][3][4] = $now;
        $this->compareExpectations(static::$drv, $state);
    }

    public function testMarkAStaleEditionUnreadAndNotHidden(): void {
        Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => false], (new Context)->edition(20)); // no changes occur
        $state = $this->primeExpectations($this->data, $this->checkTables);
        $this->compareExpectations(static::$drv, $state);
    }

    public function testSelectScrapedContent(): void {
        $exp = [
            ['id' => 101, 'content' => "<p>Article content 1</p>"],
            ['id' => 102, 'content' => "<p>Article content 2</p>"],
        ];
        $this->assertResult($exp, Arsse::$db->articleList("john.doe@example.org", (new Context)->subscription(8), ["id", "content"]));
        $exp = [
            ['id' => 101, 'content' => "<p>Scraped content 1</p>"],
            ['id' => 102, 'content' => "<p>Article content 2</p>"],
        ];
        $this->assertResult($exp, Arsse::$db->articleList("jill.doe@example.com", (new Context)->subscription(15), ["id", "content"]));
    }

    public function testSearchScrapedContent(): void {
        $exp = [
            ['id' => 101, 'content' => "<p>Scraped content 1</p>"],
            ['id' => 102, 'content' => "<p>Article content 2</p>"],
        ];
        $this->assertResult($exp, Arsse::$db->articleList("jill.doe@example.com", (new Context)->subscription(15)->searchTerms(["article"]), ["id", "content"]));
        $exp = [
            ['id' => 101, 'content' => "<p>Scraped content 1</p>"],
        ];
        $this->assertResult($exp, Arsse::$db->articleList("jill.doe@example.com", (new Context)->subscription(15)->searchTerms(["scraped"]), ["id", "content"]));
    }
}