Trog-DataSource.t 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. #!/usr/bin/env perl
  2. # A datasource's posts are built fresh on every view and have never been near
  3. # the datastore, so the search the reader typed -- which get() applied to the
  4. # datastore and then threw away along with the posts it filtered -- has to be
  5. # applied to them by somebody. This is that somebody.
  6. use 5.040;
  7. use re '/aasx';
  8. use warnings FATAL => 'all';
  9. use FindBin;
  10. use FindBin::libs;
  11. use Test::More;
  12. use Test::Fatal qw{exception};
  13. require_ok('Trog::DataSource') or BAIL_OUT("Can't find SUT");
  14. my $t = 1700000000;
  15. sub guest {
  16. my ( $title, %o ) = @_;
  17. return {
  18. id => "id-$title",
  19. title => $title,
  20. data => '',
  21. created => $t++,
  22. user => 'bob',
  23. version => 0,
  24. %o,
  25. };
  26. }
  27. my @posts = (
  28. guest( 'webserver', state => 'running', hypervisor_title => 'Rack One' ),
  29. guest( 'database', state => 'shut off', hypervisor_title => 'Rack One' ),
  30. guest( 'mailhost', state => 'running', hypervisor_title => 'Rack Two', user => 'ann' ),
  31. );
  32. sub titles {
  33. my (@got) = @_;
  34. return [ sort map { $_->{title} } @got ];
  35. }
  36. subtest 'the default filter searches title and body' => sub {
  37. is_deeply( titles( Trog::DataSource::filter( {}, @posts ) ), titles(@posts), 'an empty query filters nothing' );
  38. is_deeply( titles( Trog::DataSource::filter( undef, @posts ) ), titles(@posts), 'and neither does no query at all' );
  39. is_deeply( titles( Trog::DataSource::filter( { like => 'web' }, @posts ) ), ['webserver'], 'a substring of the title matches' );
  40. is_deeply( titles( Trog::DataSource::filter( { like => 'SERVER' }, @posts ) ), ['webserver'], 'case insensitively' );
  41. is_deeply( titles( Trog::DataSource::filter( { like => 'erve' }, @posts ) ), ['webserver'], 'inside a word' );
  42. is_deeply( titles( Trog::DataSource::filter( { like => 'nonesuch' }, @posts ) ), [], 'and nothing matches nothing' );
  43. # The body counts too, for a source whose posts have one.
  44. my @bodied = ( guest( 'notes', data => 'the quick brown fox' ) );
  45. is_deeply( titles( Trog::DataSource::filter( { like => 'quick' }, @bodied ) ), ['notes'], 'the body is searched as well' );
  46. # The default does NOT know about a source's own fields -- that is what a
  47. # source implementing filter() is for.
  48. is_deeply( titles( Trog::DataSource::filter( { like => 'Rack One' }, @posts ) ), [], 'but a field it has never heard of is not' );
  49. };
  50. subtest 'the rest of the query still means what it means' => sub {
  51. is_deeply( titles( Trog::DataSource::filter( { author => 'ann' }, @posts ) ), ['mailhost'], 'author filters on the post user' );
  52. is_deeply( titles( Trog::DataSource::filter( { author => 'nobody' }, @posts ) ), [], 'and an author with nothing gets nothing' );
  53. is_deeply( titles( Trog::DataSource::filter( { older => $posts[1]{created} }, @posts ) ), ['webserver'], 'older is older' );
  54. is_deeply( titles( Trog::DataSource::filter( { newer => $posts[1]{created} }, @posts ) ), ['mailhost'], 'and newer is newer' );
  55. # Straight off a query string, so it is coerced rather than trusted.
  56. is_deeply(
  57. titles( Trog::DataSource::filter( { older => "$posts[1]{created}abc" }, @posts ) ),
  58. ['webserver'],
  59. 'a non-numeric older is coerced rather than dying'
  60. );
  61. # Combined, the way a search on a filtered page arrives.
  62. is_deeply( titles( Trog::DataSource::filter( { like => 'host', author => 'ann' }, @posts ) ), ['mailhost'], 'filters compose' );
  63. is_deeply( titles( Trog::DataSource::filter( { like => 'host', author => 'bob' }, @posts ) ), [], 'and disagree when they should' );
  64. };
  65. subtest 'what the default deliberately does not do' => sub {
  66. # Tags and acls are answers about the datastore, and they are settled before
  67. # a datasource is reached -- the reader got to the series page by holding
  68. # its acls, and every synthesized post belongs to that series. Applying
  69. # them again would blank the page for a source whose posts carry no tags,
  70. # which nothing requires them to.
  71. is_deeply( titles( Trog::DataSource::filter( { acls => [] }, @posts ) ), titles(@posts), 'an empty acl list does not blank the page' );
  72. is_deeply( titles( Trog::DataSource::filter( { tags => ['nonesuch'] }, @posts ) ), titles(@posts), 'nor does a tag nothing carries' );
  73. # An id query would otherwise match the series, not its children, and leave
  74. # nothing at all.
  75. is_deeply( titles( Trog::DataSource::filter( { id => 'the-series' }, @posts ) ), titles(@posts), 'and neither does the series id' );
  76. };
  77. subtest 'searchable_match' => sub {
  78. ok( Trog::DataSource::searchable_match( 'ell', 'hello' ), 'a substring matches' );
  79. ok( Trog::DataSource::searchable_match( 'ELL', 'hello' ), 'case insensitively' );
  80. ok( Trog::DataSource::searchable_match( 'x', 'a', undef, 'x' ), 'across several values' );
  81. ok( !Trog::DataSource::searchable_match(qw{zz a b}), 'and misses when it should' );
  82. ok( !Trog::DataSource::searchable_match( '', 'anything' ), 'an empty search matches nothing' );
  83. ok( !Trog::DataSource::searchable_match( undef, 'anything' ), 'and neither does no search' );
  84. # Quotemeta'd, so a search for punctuation is a search for punctuation.
  85. ok( !Trog::DataSource::searchable_match( '.*', 'anything' ), 'a regex is text, not a pattern' );
  86. ok( Trog::DataSource::searchable_match( '.*', 'a .* b' ), 'and matches where that text is' );
  87. is( exception { Trog::DataSource::searchable_match( '(unclosed', 'x' ) }, undef, 'even when it would not compile' );
  88. # A body stored as an array would otherwise be matched against its address.
  89. ok( !Trog::DataSource::searchable_match( 'ARRAY', ['slide one'] ), 'a reference is skipped, not stringified' );
  90. };
  91. subtest 'for_type' => sub {
  92. is( Trog::DataSource::for_type(), undef, 'no type names no source' );
  93. is( Trog::DataSource::for_type(''), undef, 'and neither does an empty one' );
  94. is( Trog::DataSource::for_type('blog.tx'), undef, 'a type with no datasource names none' );
  95. };
  96. subtest 'cacheable' => sub {
  97. # A datasource page is cached like any other unless something says not to,
  98. # and nothing else ever invalidates it: saving a post does not, because no
  99. # post was saved. A source that cannot say when its posts go stale would
  100. # therefore be cached forever wrong.
  101. is( Trog::DataSource::cacheable('Trog::DataSource::TestPlain'), 0, 'a source which says nothing is not cacheable' );
  102. is( Trog::DataSource::cacheable(undef), 0, 'and neither is no source at all' );
  103. require Trog::DataSource::Virt;
  104. is( Trog::DataSource::cacheable('Trog::DataSource::Virt'), 0, 'Virt is not: a guest\'s state is the point of the page' );
  105. require Trog::DataSource::DirIndex;
  106. is( Trog::DataSource::cacheable('Trog::DataSource::DirIndex'), 1, 'DirIndex is, because it watches the directory' );
  107. };
  108. subtest 'the route marks an uncacheable page nocache' => sub {
  109. require Trog::Routes::HTML;
  110. require Test::MockModule;
  111. my $meta = Test::MockModule->new('Trog::DataModule');
  112. my $source;
  113. $meta->redefine( type_meta_for => sub { return $source ? { 'x-tcms-datasource' => $source } : {} } );
  114. my $query = sub { return { primary_post => { child_form => 'fake.tx' } } };
  115. # An ordinary page is left alone.
  116. $source = undef;
  117. my $ordinary = $query->();
  118. Trog::Routes::HTML::_datasource_posts( $ordinary, [ guest('stored') ] );
  119. ok( !$ordinary->{nocache}, 'an ordinary page is not marked' );
  120. $source = 'Trog::DataSource::TestPlain';
  121. my $uncacheable = $query->();
  122. Trog::Routes::HTML::_datasource_posts( $uncacheable, [] );
  123. is( $uncacheable->{nocache}, 1, 'a source which has not said is marked nocache' );
  124. $source = 'Trog::DataSource::TestCached';
  125. my $cacheable = $query->();
  126. Trog::Routes::HTML::_datasource_posts( $cacheable, [] );
  127. ok( !$cacheable->{nocache}, 'and one which has said so is not' );
  128. };
  129. subtest 'nocache on the query reaches the renderer' => sub {
  130. require Trog::Renderer;
  131. # TCMS::build_routes puts a route's nocache flag on the query, and exactly
  132. # one caller has ever forwarded it to render() as an option. Everything
  133. # else was relying on being auth-gated, which says nothing about a route
  134. # that is nocache and public -- which is what an uncacheable datasource
  135. # page is.
  136. my @saved;
  137. my $tpsgi = bless {}, 'FakeTPSGI2';
  138. {
  139. no warnings qw{once};
  140. *FakeTPSGI2::add_post_close_callback = sub { $_[1]->(); return 1 };
  141. *FakeTPSGI2::save_render = sub { push( @saved, $_[1] ); return 1 };
  142. }
  143. # The dispatch table holds a code ref taken at load time, so redefining the
  144. # sub it came from leaves the table pointing at the original. Localise the
  145. # entry instead.
  146. no warnings qw{once};
  147. local $Trog::Renderer::renderers{html} = sub { return [ 200, [], ['a page'] ] };
  148. my %common = ( contenttype => 'text/html', template => 'whatever.tx', code => 200 );
  149. Trog::Renderer->render( %common, data => { route => '/cacheable', tpsgi => $tpsgi } );
  150. is_deeply( \@saved, ['/cacheable'], 'an ordinary page is saved as a static' );
  151. @saved = ();
  152. Trog::Renderer->render( %common, data => { route => '/live', tpsgi => $tpsgi, nocache => 1 } );
  153. is_deeply( \@saved, [], 'one whose query says nocache is not' );
  154. };
  155. subtest 'the defaults are advertised' => sub {
  156. ok( length Trog::DataSource::lang(), 'there is a language to advertise' );
  157. like( Trog::DataSource::help(), qr{^https?://}, 'and somewhere to read about it' );
  158. };
  159. subtest 'Trog::DataSource::Virt searches what its posts carry' => sub {
  160. require_ok('Trog::DataSource::Virt') or return;
  161. # A guest post's body is empty; everything about it is in named fields.
  162. is_deeply( titles( Trog::DataSource::Virt::filter( { like => 'web' }, @posts ) ), ['webserver'], 'the guest name is searched' );
  163. is_deeply( titles( Trog::DataSource::Virt::filter( { like => 'shut' }, @posts ) ), ['database'], 'and so is its state' );
  164. is_deeply( titles( Trog::DataSource::Virt::filter( { like => 'Rack One' }, @posts ) ), [ sort qw{database webserver} ], 'and the hypervisor it is on' );
  165. is_deeply( titles( Trog::DataSource::Virt::filter( { like => 'nonesuch' }, @posts ) ), [], 'and nothing matches nothing' );
  166. # The rest of the query is still the default's job.
  167. is_deeply( titles( Trog::DataSource::Virt::filter( { author => 'ann' }, @posts ) ), ['mailhost'], 'author still filters' );
  168. is_deeply( titles( Trog::DataSource::Virt::filter( { like => 'running', author => 'ann' }, @posts ) ), ['mailhost'], 'alongside the search' );
  169. # It says so, rather than letting the page advertise the data model's.
  170. isnt( Trog::DataSource::Virt::lang(), Trog::DataSource::lang(), 'and it describes its own search' );
  171. like( Trog::DataSource::Virt::lang(), qr/guest/i, 'in terms of what is on the page' );
  172. };
  173. #--------------------------------------------------------------------------
  174. # The wiring: a route has to actually reach for all this.
  175. #--------------------------------------------------------------------------
  176. BEGIN {
  177. $INC{'Trog/DataSource/TestPlain.pm'} = 1;
  178. $INC{'Trog/DataSource/TestPicky.pm'} = 1;
  179. $INC{'Trog/DataSource/TestBroken.pm'} = 1;
  180. $INC{'Trog/DataSource/TestCached.pm'} = 1;
  181. }
  182. {
  183. # A source with no filter() of its own: the default has to be applied for it.
  184. package Trog::DataSource::TestPlain;
  185. sub posts { return ( main::guest('alpha'), main::guest('beta') ) }
  186. # One which can say when its posts go stale, and so may be cached.
  187. package Trog::DataSource::TestCached;
  188. use constant CACHEABLE => 1;
  189. sub posts { return ( main::guest('cached') ) }
  190. # One with opinions, including its own search language.
  191. package Trog::DataSource::TestPicky;
  192. our @FILTERED;
  193. sub posts { return ( main::guest('alpha'), main::guest('beta') ) }
  194. sub filter {
  195. my ( $q, @p ) = @_;
  196. push @FILTERED, $q->{like};
  197. return grep { $_->{title} eq 'beta' } @p;
  198. }
  199. sub lang { return 'Picky search' }
  200. sub help { return 'https://example.com/picky' }
  201. # One whose filter blows up.
  202. package Trog::DataSource::TestBroken;
  203. sub posts { return ( main::guest('alpha') ) }
  204. sub filter { die "no\n" }
  205. }
  206. subtest 'the route applies the search to synthesized posts' => sub {
  207. require Trog::Routes::HTML;
  208. require Test::MockModule;
  209. my $meta = Test::MockModule->new('Trog::DataModule');
  210. my $source;
  211. $meta->redefine( type_meta_for => sub { return $source ? { 'x-tcms-datasource' => $source } : {} } );
  212. my $query = sub { return { primary_post => { child_form => 'fake.tx' }, @_ } };
  213. # No datasource at all: the stored posts are handed straight back.
  214. $source = undef;
  215. my @stored = ( guest('stored') );
  216. is_deeply( titles( Trog::Routes::HTML::_datasource_posts( $query->(), \@stored ) ), ['stored'], 'an ordinary type is left alone' );
  217. $source = 'Trog::DataSource::TestPlain';
  218. is_deeply( titles( Trog::Routes::HTML::_datasource_posts( $query->(), \@stored ) ), [ sort qw{alpha beta} ], 'a datasource replaces them' );
  219. # The point of the exercise.
  220. is_deeply(
  221. titles( Trog::Routes::HTML::_datasource_posts( $query->( like => 'alph' ), \@stored ) ),
  222. ['alpha'],
  223. 'and the search reaches them, rather than being dropped'
  224. );
  225. is_deeply(
  226. titles( Trog::Routes::HTML::_datasource_posts( $query->( like => 'nonesuch' ), \@stored ) ),
  227. [],
  228. 'including when it matches nothing'
  229. );
  230. # A source with its own filter gets to use it.
  231. $source = 'Trog::DataSource::TestPicky';
  232. @Trog::DataSource::TestPicky::FILTERED = ();
  233. is_deeply(
  234. titles( Trog::Routes::HTML::_datasource_posts( $query->( like => 'alph' ), \@stored ) ),
  235. ['beta'],
  236. "a source's own filter wins over the default"
  237. );
  238. is_deeply( \@Trog::DataSource::TestPicky::FILTERED, ['alph'], 'and it is handed the query' );
  239. # A filter that dies must not take the page with it.
  240. $source = 'Trog::DataSource::TestBroken';
  241. my @survived;
  242. {
  243. my $log = Test::MockModule->new('Trog::Routes::HTML');
  244. $log->redefine( WARN => sub { note(shift) } );
  245. @survived = Trog::Routes::HTML::_datasource_posts( $query->( like => 'x' ), \@stored );
  246. }
  247. is_deeply( titles(@survived), ['alpha'], 'a filter that dies falls back to the unfiltered posts' );
  248. # A sidecar cannot make us load whatever it likes.
  249. $source = 'Evil::Module';
  250. my @refused;
  251. {
  252. my $log = Test::MockModule->new('Trog::DataSource');
  253. $log->redefine( WARN => sub { note(shift) } );
  254. @refused = Trog::Routes::HTML::_datasource_posts( $query->(), \@stored );
  255. }
  256. is_deeply( titles(@refused), ['stored'], 'a datasource outside the namespace is refused' );
  257. };
  258. subtest 'the search box says what it is searching' => sub {
  259. require Trog::Routes::HTML;
  260. require Test::MockModule;
  261. # Stubbed rather than built: constructing a real model here would create a
  262. # datastore in the repo, and none of this is about which model is configured.
  263. my $data = Test::MockModule->new('Trog::Data');
  264. $data->redefine( new => sub { return bless {}, 'FakeModel' } );
  265. {
  266. no warnings qw{once};
  267. *FakeModel::lang = sub { 'Model language' };
  268. *FakeModel::help = sub { 'https://example.com/model' };
  269. }
  270. my $meta = Test::MockModule->new('Trog::DataModule');
  271. my $source;
  272. $meta->redefine( type_meta_for => sub { return $source ? { 'x-tcms-datasource' => $source } : {} } );
  273. $source = undef;
  274. is_deeply(
  275. [ Trog::Routes::HTML::_search_language( { primary_post => { child_form => 'blog.tx' } } ) ],
  276. [ 'Model language', 'https://example.com/model' ],
  277. 'an ordinary page advertises the data model'
  278. );
  279. is_deeply(
  280. [ Trog::Routes::HTML::_search_language( {} ) ],
  281. [ 'Model language', 'https://example.com/model' ],
  282. 'and so does a page with no series at all'
  283. );
  284. # A datasource page is not being served out of the datastore, so the model's
  285. # query language is the wrong thing to advertise on it.
  286. $source = 'Trog::DataSource::TestPlain';
  287. is_deeply(
  288. [ Trog::Routes::HTML::_search_language( { primary_post => { child_form => 'fake.tx' } } ) ],
  289. [ Trog::DataSource::lang(), Trog::DataSource::help() ],
  290. 'a datasource page advertises the datasource default'
  291. );
  292. $source = 'Trog::DataSource::TestPicky';
  293. is_deeply(
  294. [ Trog::Routes::HTML::_search_language( { primary_post => { child_form => 'fake.tx' } } ) ],
  295. [ 'Picky search', 'https://example.com/picky' ],
  296. 'or its own, when it has one'
  297. );
  298. };
  299. subtest 'ordering is total, so page 2 means something' => sub {
  300. my @same = (
  301. guest( 'charlie', created => 100 ),
  302. guest( 'alpha', created => 100 ),
  303. guest( 'bravo', created => 100 ),
  304. guest( 'delta', created => 200 ),
  305. );
  306. my @ordered = Trog::DataSource::order( {}, @same );
  307. is_deeply( [ map { $_->{title} } @ordered ], [qw{delta alpha bravo charlie}], 'newest first, then by title' );
  308. # The property that actually matters: same input, same order, every time.
  309. is_deeply(
  310. [ map { $_->{title} } Trog::DataSource::order( {}, reverse @same ) ],
  311. [ map { $_->{title} } @ordered ],
  312. 'and it does not depend on what order they arrived in'
  313. );
  314. # A source that stamps everything with the time it built them -- which is
  315. # every libvirt guest -- falls back to being sorted by name.
  316. my @built = map { guest( $_, created => 1700 ) } qw{zulu alpha mike};
  317. is_deeply( [ map { $_->{title} } Trog::DataSource::order( {}, @built ) ], [qw{alpha mike zulu}], 'identical timestamps sort by name' );
  318. # Missing fields must not make it die or shuffle.
  319. my @sparse = ( { id => 'b' }, { id => 'a' }, guest('titled') );
  320. is( exception { Trog::DataSource::order( {}, @sparse ) }, undef, 'posts missing created and title are still orderable' );
  321. };
  322. subtest 'offset pagination' => sub {
  323. require Trog::Routes::HTML;
  324. my @ten = map { guest( sprintf( 'g%02d', $_ ), created => 100 + $_ ) } 1 .. 10;
  325. my ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 1 }, 4, @ten );
  326. is( $page, 1, 'page one is page one' );
  327. is( $total, 3, 'ten posts at four a page is three pages' );
  328. is( scalar(@slice), 4, 'and a full page' );
  329. is( $slice[0]{title}, 'g01', 'starting at the beginning' );
  330. ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 2 }, 4, @ten );
  331. is( $slice[0]{title}, 'g05', 'page two carries on where page one stopped' );
  332. is( scalar(@slice), 4, 'and is also full' );
  333. ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 3 }, 4, @ten );
  334. is( scalar(@slice), 2, 'the last page holds the remainder' );
  335. is( $slice[0]{title}, 'g09', 'from where page two stopped' );
  336. # Every post appears exactly once across the pages, which is the whole point.
  337. my @walked;
  338. push( @walked, ( Trog::Routes::HTML::_paginate_offset( { page => $_ }, 4, @ten ) )[ 2 .. 5 ] ) for 1 .. 3;
  339. @walked = grep { defined } @walked;
  340. is_deeply( [ sort map { $_->{title} } @walked ], [ sort map { $_->{title} } @ten ], 'walking the pages visits everything, once' );
  341. # Straight off a query string.
  342. ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 99 }, 4, @ten );
  343. is( $page, 3, 'a page past the end shows the last one' );
  344. ok( scalar(@slice), 'rather than nothing at all' );
  345. ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 0 }, 4, @ten );
  346. is( $page, 1, 'and page zero is page one' );
  347. ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 'nonsense' }, 4, @ten );
  348. is( $page, 1, 'as is a page that is not a number' );
  349. ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( {}, 4, @ten );
  350. is( $page, 1, 'and no page at all' );
  351. # A limit that would divide by zero.
  352. ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 1 }, 0, @ten );
  353. is( $total, 1, 'a limit of zero falls back to the default rather than dividing by it' );
  354. is( scalar(@slice), 10, 'and shows everything' );
  355. ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 1 }, 4 );
  356. is( $total, 1, 'no posts is one empty page' );
  357. is( scalar(@slice), 0, 'holding nothing' );
  358. ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 1 }, 25, @ten );
  359. is( $total, 1, 'fewer posts than a page is one page' );
  360. is( scalar(@slice), 10, 'holding all of them' );
  361. };
  362. subtest 'the paginator renders page links for a datasource' => sub {
  363. require Trog::Renderer::Base;
  364. my $render = sub {
  365. my (%vars) = @_;
  366. return Trog::Renderer::Base::render(
  367. template => 'paginator.tx',
  368. contenttype => 'text/html',
  369. component => 1,
  370. data => { limit => 25, sizes => [ 25, 50, 100 ], years => [2024], months => [ 0 .. 11 ], older => 0, newer => 0, %vars },
  371. );
  372. };
  373. my $offset = $render->( paginate_offset => 1, pages => 1, page => 2, pages_total => 5, like => 'web' );
  374. like( $offset, qr/Page[ ]2[ ]of[ ]5/, 'it says where you are' );
  375. like( $offset, qr/href="\?page=1&limit=25/, 'Prev goes back a page' );
  376. like( $offset, qr/href="\?page=3&limit=25/, 'Next goes on a page' );
  377. # & because Xslate escapes what an expression emits, which is what an
  378. # ampersand in an attribute is supposed to be. The cursor branch is the same.
  379. like( $offset, qr/like=web/, 'and the search survives the trip' );
  380. unlike( $offset, qr/older=|newer=/, 'no cursor, which could not move on such a page anyway' );
  381. unlike( $offset, qr/Jump[ ]to/, 'and no date jump, since these posts share a date' );
  382. my $first = $render->( paginate_offset => 1, pages => 1, page => 1, pages_total => 3 );
  383. unlike( $first, qr/rel="prev"/, 'the first page offers no Prev' );
  384. like( $first, qr/rel="next"/, 'but does offer Next' );
  385. my $last = $render->( paginate_offset => 1, pages => 1, page => 3, pages_total => 3 );
  386. like( $last, qr/rel="prev"/, 'the last page offers Prev' );
  387. unlike( $last, qr/rel="next"/, 'and no Next' );
  388. my $only = $render->( paginate_offset => 1, pages => 0, page => 1, pages_total => 1 );
  389. unlike( $only, qr/rel="prev"|rel="next"/, 'a single page offers neither' );
  390. like( $only, qr/id="paginator"[ ]class="disabled"/, 'and says so' );
  391. # Stored posts are untouched by any of this.
  392. my $cursor = $render->( paginate_offset => 0, pages => 1, older => 1700, newer => 1800, like => 'x' );
  393. like( $cursor, qr/href="\?older=1700&limit=25/, 'the cursor paginator still pages by cursor' );
  394. like( $cursor, qr/href="\?newer=1800&limit=25/, 'both ways' );
  395. like( $cursor, qr/Jump[ ]to/, 'and keeps its date jump' );
  396. unlike( $cursor, qr/\?page=/, 'with no page numbers' );
  397. };
  398. done_testing();