ankurah-virtual-scroll 0.7.7

Platform-agnostic virtual scroll state machine with pagination for Ankurah
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
//! ScrollManager integration tests
//!
//! ## Standard Test Configuration
//!
//! Most tests use: 60 messages (ts 1000-1059), 50px height, 500px viewport (10 visible).
//! - S = 10 screen_items (viewport_height / min_row_height)
//! - B = 20 buffer (buffer_factor * S = 2 * 10)
//! - live_window = 30 ((2N + 1) * S where N = buffer_factor / 2 = 1)
//! - Trigger threshold = 10 items (items_above/below <= S)
//! - Limit = 50 (visible_span + 2*buffer = 10 + 40)
//!
//! ## Scroll Stability (Anchor vs Cursor)
//!
//! The ANCHOR is the visible edge item used for scroll stability:
//! - Backward pagination: anchor = newest_visible (bottom of visible area)
//! - Forward pagination: anchor = oldest_visible (top of visible area)
//!
//! The CURSOR is a different item used for the query boundary:
//! - Backward: cursor = (newest_visible + buffer).min(max) → query <= cursor_ts
//! - Forward: cursor = (oldest_visible - buffer).max(0) → query >= cursor_ts
//!
//! The frontend uses the anchor to maintain scroll position after pagination.
//!
//! ## Race Condition Fix
//!
//! When using ephemeral nodes with remote subscriptions, the subscription callback can fire
//! multiple times during a single pagination request. The fix uses `resultset.is_loaded()`
//! to detect when the full result is ready before consuming the pending slide state.
//!
//! Note: These tests use durable sled storage which processes results synchronously.

mod common;

use common::*;
use std::sync::Arc;

/// Test full round-trip: Live → oldest edge → back to Live.
///
/// Math trace for first backward pagination:
/// - Initial: 30 items (1030-1059), scroll_offset=1000 (auto-scroll to bottom)
/// - up_no_render(400): offset=600, visible=1042-1051
/// - scroll_up(100): offset=500, visible=1040-1049, items_above=10 → TRIGGER
/// - cursor_index = min(19+20, 29) = 29 → continuation at ts=1059
/// - Result: 50 items (1010-1059), intersection at index 49 (ts=1059)
/// - Backward anchor: scroll_offset = 2500-500 = 2000, visible=1050-1059
#[tokio::test]
async fn test_scroll_live_to_oldest_and_back() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..60).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,  // min_row_height
        2.0, // buffer_factor
        500, // viewport_height
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    // Initial: Live mode, 30 items (1030-1059), auto-scrolled to bottom
    let vs = r.next_render().await?;
    r.assert(&vs, 30, 1030..=1059, None, true, false, true, 1050, 1059);
    assert_eq!(r.scroll_offset, 1000);

    // === PHASE 1: Scroll backward to oldest edge ===

    // Scroll up: offset 1000→600, visible 1042-1051, exits Live mode
    r.scroll_up_and_expect(
        400, 30, 1030..=1059, None,  // same items, no pagination yet
        true, false, false,          // mode changed, should_auto_scroll=false
        1042, 1051, 600,
        None,
    ).await?;

    // Trigger backward: offset 600→500, visible indices 10-19 → ts 1040-1049, items_above=10
    // For backward: anchor = newest_visible = ts 1049
    // After: 50 items, anchor at index 39 → visible indices 30-39, offset 1500
    r.scroll_up_and_expect(
        100, 50, 1010..=1059, Some(1049),
        true, true, false, 1040, 1049, 1500,
        Some("TRUE AND \"timestamp\" <= 1059 ORDER BY timestamp DESC LIMIT 51"),
    ).await?;

    // Continue scrolling backward toward oldest edge
    // After first pagination: 50 items (1010-1059), offset=1500
    // offset 1500→1000: visible indices 20-29 → ts 1030-1039
    r.up_no_render(500, 1030, 1039).await;

    // offset 1000→500: visible 1020-1029, items_above=10 → TRIGGER
    // For backward: anchor = newest_visible = ts 1029
    // After: 50 items (1000-1049), anchor at index 29 → visible 20-29, offset 1000
    r.scroll_up_and_expect(
        500, 50, 1000..=1049, Some(1029),
        false, true, false, 1020, 1029, 1000,
        Some("TRUE AND \"timestamp\" <= 1049 ORDER BY timestamp DESC LIMIT 51"),
    ).await?;

    // Scroll to top (50 items 1000-1049, starting at offset 1000)
    // offset 1000→500: visible 1010-1019
    r.up_no_render(500, 1010, 1019).await;
    // offset 500→0: visible 1000-1009
    r.up_no_render(500, 1000, 1009).await;
    assert_eq!(r.scroll_offset, 0);

    // === PHASE 2: Scroll forward back to live edge ===
    // From offset 0, scroll down through 50 items (1000-1049)

    // offset 0→500: visible 1010-1019
    r.down_no_render(500, 1010, 1019).await;
    // offset 500→1000: visible 1020-1029
    r.down_no_render(500, 1020, 1029).await;
    // offset 1000→1500: visible 1030-1039, items_below=10 → TRIGGER (exactly at threshold!)
    // cursor_index = (30 - 20).max(0) = 10 → ts = 1000+10 = 1010
    // Query: timestamp >= 1010 ORDER BY ASC LIMIT 51 → returns 1010-1059 = 50 items
    // Window slides forward, dropping items 1000-1009
    // Intersection at ts=1030, auto-scroll to bottom
    // 50 items * 50px = 2500px content, offset = 2500 - 500 = 2000
    r.scroll_down_and_expect(
        500, 50, 1010..=1059, Some(1030),
        true, false, true, 1050, 1059, 2000,
        Some("TRUE AND \"timestamp\" >= 1010 ORDER BY timestamp ASC LIMIT 51"),
    ).await?;

    assert_eq!(sm.mode(), ankurah_virtual_scroll::ScrollMode::Live);

    Ok(())
}

/// Test scrolling to the absolute oldest item.
/// Verifies has_more_preceding becomes false when at the edge.
#[tokio::test]
async fn test_scroll_to_absolute_oldest() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..60).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    // Initial: 30 items (1030-1059)
    let vs = r.next_render().await?;
    r.assert(&vs, 30, 1030..=1059, None, true, false, true, 1050, 1059);

    // First backward pagination: 30 → 50 items
    // scroll_up(400): offset 1000→600, exits Live mode (mode-change render)
    r.scroll_up_and_expect(
        400, 30, 1030..=1059, None,  // same items, no pagination yet
        true, false, false,          // mode changed, should_auto_scroll=false
        1042, 1051, 600,
        None,
    ).await?;
    // scroll_up(100): offset 500, visible indices 10-19 → ts 1040-1049
    // For backward: anchor = newest_visible = ts 1049
    // After: 50 items, anchor at index 39 → visible indices 30-39, offset 1500
    r.scroll_up_and_expect(
        100, 50, 1010..=1059, Some(1049),
        true, true, false, 1040, 1049, 1500,
        Some("TRUE AND \"timestamp\" <= 1059 ORDER BY timestamp DESC LIMIT 51"),
    ).await?;

    // Continue backward until we hit the oldest edge
    // Starting at offset 1500, use 500px increments
    // offset 1500→1000: visible indices 20-29 → ts 1030-1039
    r.up_no_render(500, 1030, 1039).await;
    // offset 1000→500: visible indices 10-19 → ts 1020-1029, items_above=10 → TRIGGER
    // anchor = newest_visible = ts 1029
    // New set 1000-1049, anchor at index 29 → visible indices 20-29, offset 1000
    let vs = r.scroll_up_and_expect(
        500, 50, 1000..=1049, Some(1029),
        false, true, false, 1020, 1029, 1000,
        Some("TRUE AND \"timestamp\" <= 1049 ORDER BY timestamp DESC LIMIT 51"),
    ).await?;

    // CRITICAL: has_more_preceding must be false
    assert!(!vs.has_more_preceding, "should have no more preceding items at oldest edge");

    Ok(())
}

/// Test with a small dataset that fits entirely within the live window.
#[tokio::test]
async fn test_small_dataset_no_pagination() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    // Only 15 items - less than live_window of 30
    create_messages(&ctx, (0..15).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    // Initial: All 15 items (1000-1014)
    // Content: 750px, viewport: 500px, auto-scroll: offset = 250
    // Visible: indices 5-14 (ts 1005-1014)
    let vs = r.next_render().await?;
    r.assert(&vs, 15, 1000..=1014, None, false, false, true, 1005, 1014);

    // Try scrolling up - exits Live mode (mode-change render) but NOT pagination (has_more_preceding=false)
    // offset 250→50, visible indices 1-10 → ts 1001-1010, items_below=4 → exits Live
    r.scroll_up_and_expect(
        200, 15, 1000..=1014, None,  // same items, no pagination
        false, false, false,         // mode changed, should_auto_scroll=false
        1001, 1010, 50,
        None,
    ).await?;
    // Continue scrolling within Backward mode
    r.up_no_render(50, 1000, 1009).await;

    assert_eq!(r.scroll_offset, 0);
    assert_eq!(sm.mode(), ankurah_virtual_scroll::ScrollMode::Backward);

    Ok(())
}

/// Test dataset with exactly one more than live window (31 items).
#[tokio::test]
async fn test_one_more_than_live_window() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    // 31 items = live_window + 1
    create_messages(&ctx, (0..31).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    // Initial: 30 items (1001-1030)
    // Content: 1500px, viewport: 500px, offset: 1000
    // Visible: indices 20-29 (ts 1021-1030)
    let vs = r.next_render().await?;
    r.assert(&vs, 30, 1001..=1030, None, true, false, true, 1021, 1030);

    // Scroll backward to get the missing item
    // up(400): offset 600, visible indices 12-21 → ts 1013-1022
    // items_below = 30 - 21 - 1 = 8 <= screen(10) → exits Live mode
    // After mode change: same items but should_auto_scroll=false
    r.scroll_up_and_expect(
        400, 30, 1001..=1030, None,
        true, false, false, 1013, 1022, 600,
        None,
    ).await?;
    // scroll_up(100): offset 500, visible indices 10-19 → ts 1011-1020
    // For backward: anchor = newest_visible = ts 1020
    // After: 31 items, anchor at index 20 → visible indices 11-20, offset 550
    let vs = r.scroll_up_and_expect(
        100, 31, 1000..=1030, Some(1020),
        false, true, false, 1011, 1020, 550,
        Some("TRUE AND \"timestamp\" <= 1030 ORDER BY timestamp DESC LIMIT 51"),
    ).await?;

    // Now has_more_preceding should be false - we have all 31 items
    assert!(!vs.has_more_preceding);

    Ok(())
}

/// Test rapid successive scrolls (debounce behavior).
#[tokio::test]
async fn test_debounce_rapid_scrolls() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..60).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    let vs = r.next_render().await?;
    r.assert(&vs, 30, 1030..=1059, None, true, false, true, 1050, 1059);

    // First scroll triggers pagination
    // scroll_up(400): offset 1000→600, exits Live mode (mode-change render)
    r.scroll_up_and_expect(
        400, 30, 1030..=1059, None,  // same items, no pagination yet
        true, false, false,          // mode changed, should_auto_scroll=false
        1042, 1051, 600,
        None,
    ).await?;
    // scroll_up(100): offset 500, visible indices 10-19 → ts 1040-1049
    // For backward: anchor = newest_visible = ts 1049
    // After: 50 items, anchor at index 39 → visible indices 30-39, offset 1500
    r.scroll_up_and_expect(
        100, 50, 1010..=1059, Some(1049),
        true, true, false, 1040, 1049, 1500,
        Some("TRUE AND \"timestamp\" <= 1059 ORDER BY timestamp DESC LIMIT 51"),
    ).await?;

    // Small scrolls within buffer - should NOT trigger new pagination
    // At offset 1500, scrolling up 50px each time
    r.up_no_render(50, 1039, 1048).await;
    r.up_no_render(50, 1038, 1047).await;

    // Mode should still be Backward
    assert_eq!(sm.mode(), ankurah_virtual_scroll::ScrollMode::Backward);

    Ok(())
}

/// Test with variable item heights.
#[tokio::test]
async fn test_variable_item_heights() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    let heights = [50, 75, 100];
    create_messages(
        &ctx,
        (0..60).map(|i| (1000 + i, heights[i as usize % 3])),
    ).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    let vs = r.next_render().await?;
    assert_eq!(vs.items.len(), 30);
    assert!(vs.has_more_preceding);

    // Verify content height with variable heights
    // Last 30 items (indices 30-59) have heights cycling 50,75,100
    let expected_content_height: i32 = (0..30).map(|i| heights[(30 + i) % 3]).sum();
    assert_eq!(r.content_height(), expected_content_height);

    Ok(())
}

/// Test empty dataset.
#[tokio::test]
async fn test_empty_dataset() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    // No messages created

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    let vs = r.next_render().await?;
    assert_eq!(vs.items.len(), 0);
    // Empty set means no more items in either direction
    // Note: start() sets has_more_preceding = items.len() >= live_window = false
    assert!(!vs.has_more_following);
    assert_eq!(sm.mode(), ankurah_virtual_scroll::ScrollMode::Live);

    Ok(())
}

/// Test single item dataset.
#[tokio::test]
async fn test_single_item() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, [(1000, 50)]).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    let vs = r.next_render().await?;
    assert_eq!(vs.items.len(), 1);
    assert!(!vs.has_more_preceding);
    assert!(!vs.has_more_following);
    assert!(vs.should_auto_scroll);

    Ok(())
}

/// Test ascending display order (oldest first).
#[tokio::test]
async fn test_ascending_order() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..60).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp ASC",  // Oldest first
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    let vs = r.next_render().await?;
    let ts = timestamps(&vs);

    // In ASC order, items should be 1000, 1001, ..., 1029
    assert_eq!(ts.len(), 30);
    assert_eq!(*ts.first().unwrap(), 1000);
    assert_eq!(*ts.last().unwrap(), 1029);

    Ok(())
}

/// Test scroll behavior with large viewport.
#[tokio::test]
async fn test_large_viewport() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..60).map(|i| (1000 + i, 50))).await?;

    // 1000px viewport = 20 visible items
    // screen_items = 20, live_window = (2*1 + 1) * 20 = 60
    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        1000,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 1000);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    // With live_window=60 and 60 items, we get all items
    let vs = r.next_render().await?;
    assert_eq!(vs.items.len(), 60);
    assert!(!vs.has_more_following);

    Ok(())
}

/// Test two items dataset.
#[tokio::test]
async fn test_two_items() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, [(1000, 50), (1001, 50)]).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    let vs = r.next_render().await?;
    assert_eq!(vs.items.len(), 2);
    assert!(!vs.has_more_preceding);
    assert!(!vs.has_more_following);
    assert!(vs.should_auto_scroll);

    let ts = timestamps(&vs);
    assert_eq!(ts, vec![1000, 1001]);

    Ok(())
}

/// Test initial auto-scroll puts us at the bottom.
#[tokio::test]
async fn test_initial_auto_scroll() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..60).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    let vs = r.next_render().await?;

    assert!(vs.should_auto_scroll);

    let ts = timestamps(&vs);
    assert!(ts.contains(&1059)); // Newest
    assert!(ts.contains(&1030)); // Oldest in window

    // content_height = 30 * 50 = 1500, viewport = 500
    // scroll_offset = 1500 - 500 = 1000
    assert_eq!(r.scroll_offset, 1000);

    Ok(())
}

/// Test mode transitions through the lifecycle.
#[tokio::test]
async fn test_mode_transitions() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..60).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    // Initial: Live mode
    let _ = r.next_render().await?;
    assert_eq!(sm.mode(), ankurah_virtual_scroll::ScrollMode::Live);

    // Scroll backward → Backward mode
    // scroll_up(400): offset 1000→600, exits Live mode (mode-change render)
    r.scroll_up_and_expect(
        400, 30, 1030..=1059, None,  // same items, no pagination yet
        true, false, false,          // mode changed, should_auto_scroll=false
        1042, 1051, 600,
        None,
    ).await?;
    // scroll_up(100): offset 500, visible indices 10-19 → ts 1040-1049
    // For backward: anchor = newest_visible = ts 1049
    // After: 50 items, anchor at index 39 → visible indices 30-39, offset 1500
    r.scroll_up_and_expect(
        100, 50, 1010..=1059, Some(1049),
        true, true, false, 1040, 1049, 1500,
        Some("TRUE AND \"timestamp\" <= 1059 ORDER BY timestamp DESC LIMIT 51"),
    ).await?;
    assert_eq!(sm.mode(), ankurah_virtual_scroll::ScrollMode::Backward);

    Ok(())
}

// ============================================================================
// Additional Edge Case Tests
// ============================================================================

/// Test exactly live_window items (30) - boundary case.
#[tokio::test]
async fn test_exactly_live_window_items() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..30).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    // Should get all 30 items
    let vs = r.next_render().await?;
    assert_eq!(vs.items.len(), 30);

    // Verify timestamps span the full range
    let ts = timestamps(&vs);
    assert_eq!(*ts.first().unwrap(), 1000);
    assert_eq!(*ts.last().unwrap(), 1029);

    // Scroll up - exits Live mode (items_below = 10 <= screen_items)
    // Mode-change render fires (has_more_preceding=true still, query hasn't completed)
    // Pagination query is triggered but returns same 30 items (no new data)
    // Since item set unchanged, only the mode-change render fires
    r.scroll_up_and_expect(
        500, 30, 1000..=1029, None,
        true, false, false, 1010, 1019, 500,
        Some("TRUE AND \"timestamp\" <= 1029 ORDER BY timestamp DESC LIMIT 51"),
    ).await?;

    // Continue scrolling to top - no additional renders (already in Backward mode)
    r.up_no_render(500, 1000, 1009).await;
    assert_eq!(r.scroll_offset, 0);

    Ok(())
}

/// Test has_more_following is false at newest edge (we start there in DESC).
#[tokio::test]
async fn test_at_newest_edge() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..60).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    // Initial: 30 newest items (1030-1059)
    let vs = r.next_render().await?;

    // CRITICAL: We're at the newest edge, has_more_following must be false
    assert!(!vs.has_more_following, "should have no more following items at newest edge");
    assert!(vs.has_more_preceding, "should have more preceding items (older)");

    // Verify we have the newest item
    let ts = timestamps(&vs);
    assert!(ts.contains(&1059), "should contain newest item");

    Ok(())
}

/// Test large scroll after small scrolls escapes debounce.
#[tokio::test]
async fn test_debounce_escape() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..60).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    let _ = r.next_render().await?;

    // First pagination - scroll_up(400) exits Live mode, scroll_up(100) triggers pagination
    r.scroll_up_and_expect(
        400, 30, 1030..=1059, None,  // same items, no pagination yet
        true, false, false,          // mode changed, should_auto_scroll=false
        1042, 1051, 600,
        None,
    ).await?;
    r.scroll_up_and_expect(
        100, 50, 1010..=1059, Some(1049),
        true, true, false, 1040, 1049, 1500,
        Some("TRUE AND \"timestamp\" <= 1059 ORDER BY timestamp DESC LIMIT 51"),
    ).await?;

    // Small scrolls that stay within debounce threshold
    r.up_no_render(50, 1039, 1048).await;  // offset 1450
    r.up_no_render(50, 1038, 1047).await;  // offset 1400
    r.up_no_render(50, 1037, 1046).await;  // offset 1350

    // Mode should still be Backward, no new pagination yet
    assert_eq!(sm.mode(), ankurah_virtual_scroll::ScrollMode::Backward);

    // Now a BIG scroll that should escape debounce and trigger pagination
    // offset 1350 → 500, visible indices 10-19 → ts 1020-1029, items_above=10 → TRIGGER
    r.scroll_up_and_expect(
        850, 50, 1000..=1049, Some(1029),
        false, true, false, 1020, 1029, 1000,
        Some("TRUE AND \"timestamp\" <= 1049 ORDER BY timestamp DESC LIMIT 51"),
    ).await?;

    Ok(())
}


/// Test immediate pagination on first big scroll.
#[tokio::test]
async fn test_immediate_pagination_trigger() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..60).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    let _ = r.next_render().await?;

    // One big scroll that immediately triggers BOTH mode change AND pagination
    // First: mode-change render (30 items, exits Live mode)
    // Then: pagination render (50 items)
    // offset 1000→500, visible indices 10-19 → ts 1040-1049, items_above=10 → TRIGGER
    r.scroll_up_and_expect(
        500, 30, 1030..=1059, None,  // mode-change render first
        true, false, false,
        1040, 1049, 500,
        None,
    ).await?;
    // Pagination render follows immediately
    let _ = r.next_render().await?;

    Ok(())
}

/// Test exactly 50 items (full_window size) - boundary case.
#[tokio::test]
async fn test_exactly_full_window_items() -> Result<(), anyhow::Error> {
    let ctx = durable_sled_setup().await?;
    create_messages(&ctx, (0..50).map(|i| (1000 + i, 50))).await?;

    let sm = Arc::new(ScrollManager::<TestMessageView>::new(
        &ctx,
        "true",
        "timestamp DESC",
        50,
        2.0,
        500,
    )?);

    let mut r = MockRenderer::new(sm.clone(), 500);
    tokio::spawn({
        let sm = sm.clone();
        async move { sm.start().await }
    });

    // Initial: 30 items (1020-1049)
    let vs = r.next_render().await?;
    assert_eq!(vs.items.len(), 30);
    assert!(vs.has_more_preceding);

    // Scroll backward - exits Live mode (items_below = 8 <= screen_items)
    // After mode change: same items but should_auto_scroll=false
    r.scroll_up_and_expect(
        400, 30, 1020..=1049, None,
        true, false, false, 1032, 1041, 600,
        None,
    ).await?;
    // Continue scrolling to trigger pagination
    // visible indices 10-19 → ts 1030-1039
    // Anchor = newest_visible (ts 1039) at new index 39
    // After: 50 items, anchor at index 39 → offset = cumulative(39) - 500 = 2000 - 500 = 1500
    let vs = r.scroll_up_and_expect(
        100, 50, 1000..=1049, Some(1039),
        false, true, false, 1030, 1039, 1500,
        Some("TRUE AND \"timestamp\" <= 1049 ORDER BY timestamp DESC LIMIT 51"),
    ).await?;

    // Now we have all 50 items
    assert_eq!(vs.items.len(), 50);
    assert!(!vs.has_more_preceding);

    Ok(())
}

// ============================================================================
// TODO: Additional test coverage needed
// See: https://github.com/ankurah/ankurah/issues/XXX (create issue)
// ============================================================================
//
// 1. test_large_viewport_no_pagination
//    - When viewport is large enough that live_window encompasses all items
//    - Verify scrolling through content doesn't trigger spurious pagination
//    - Challenge: Calculating exact visible items with 1000px viewport
//
// 2. test_multiple_backward_paginations (3+ paginations)
//    - With 100+ items, require 3-4 backward paginations to reach oldest
//    - Verify each pagination maintains scroll stability
//    - Verify eventually reaches has_more_preceding = false
//    - Challenge: Tracing exact timestamp ranges through multiple window slides
//
// 3. test_forward_pagination_dedicated
//    - Start from oldest, scroll forward to newest (ASC order or after backward)
//    - Currently forward is only tested in test_scroll_live_to_oldest_and_back
//
// 4. test_real_time_insertion
//    - New items arriving while scrolling backward
//    - Verify scroll position stability when items are added
//
// 5. test_predicate_filtering
//    - Tests with non-trivial predicates (not just "true")
//    - Verify pagination works correctly with filtered datasets