feat(p0.5): continuous-single page windowing math (step 10, automatable slice)
PageStackMetrics + PageWindow: the pure geometry that decides which pages are
mounted for a scroll position (windowed lazy hosting → 60fps on a 300-page doc,
R1). Pages stack vertically with cumulative tops (O(log n) binary-search
visibleRange); a viewport [scroll, scroll+extent) grown by cacheExtent on each
side selects the inclusive intersecting page band, half-open at page boundaries,
clamped to valid indices, empty for empty/over-scrolled-past documents, and
gap-aware (a scroll resting inside an inter-page gap shows no page).
Widget-free + pdfrx-free by design: the page-mounting widget and zoom-settle DPI
refresh are device-gated; only the windowing math is automatable, and it is here
with exhaustive unit coverage (boundaries, cache band, clamping, gaps, empty).
flutter analyze lib/editor clean; 115/115 tests (+13).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 03:08:03 +08:00
|
|
|
// Unit tests for continuous-single windowing math (P0.5 step 10, automatable
|
|
|
|
|
// slice). Pure geometry — no widgets, no pdfrx.
|
|
|
|
|
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
|
|
|
|
|
import 'package:badnote/editor/layout/page_viewport.dart';
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
group('PageStackMetrics geometry', () {
|
|
|
|
|
test('cumulative tops and total extent (uniform pages + gap)', () {
|
|
|
|
|
final m = PageStackMetrics(pageHeights: [100, 100, 100], gap: 10);
|
|
|
|
|
expect(m.pageCount, 3);
|
|
|
|
|
expect(m.offsetOf(0), 0);
|
|
|
|
|
expect(m.offsetOf(1), 110); // 100 + 10 gap
|
|
|
|
|
expect(m.offsetOf(2), 220);
|
|
|
|
|
expect(m.totalExtent, 320); // 220 + last height 100, no trailing gap
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('variable page heights', () {
|
|
|
|
|
final m = PageStackMetrics(pageHeights: [50, 200, 75]);
|
|
|
|
|
expect(m.offsetOf(0), 0);
|
|
|
|
|
expect(m.offsetOf(1), 50);
|
|
|
|
|
expect(m.offsetOf(2), 250);
|
|
|
|
|
expect(m.totalExtent, 325);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('empty document', () {
|
|
|
|
|
final m = PageStackMetrics(pageHeights: []);
|
|
|
|
|
expect(m.pageCount, 0);
|
|
|
|
|
expect(m.totalExtent, 0);
|
|
|
|
|
expect(m.visibleRange(0, 800), PageWindow.empty);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('PageStackMetrics.visibleRange', () {
|
|
|
|
|
final m = PageStackMetrics(pageHeights: List.filled(10, 100)); // 0..1000
|
|
|
|
|
|
|
|
|
|
test('top of document shows the first pages', () {
|
|
|
|
|
// viewport [0,250) → pages 0,1,2 (page 2 is [200,300), overlaps top 250)
|
|
|
|
|
expect(m.visibleRange(0, 250), const PageWindow(0, 2));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('mid scroll shows the intersecting band', () {
|
|
|
|
|
// viewport [420,720) → pages 4 [400,500) .. 7 [700,800)
|
|
|
|
|
expect(m.visibleRange(420, 300), const PageWindow(4, 7));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('cacheExtent grows the window on both sides', () {
|
|
|
|
|
// base [420,720) → 4..7; +150 cache → [270,870) → pages 2..8
|
|
|
|
|
expect(
|
|
|
|
|
m.visibleRange(420, 300, cacheExtent: 150),
|
|
|
|
|
const PageWindow(2, 8),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('exact page boundary is half-open (no spurious extra page)', () {
|
|
|
|
|
// viewport [0,200): page 0 [0,100), page 1 [100,200). Page 2 starts at
|
|
|
|
|
// 200 which is the exclusive bottom → NOT included.
|
|
|
|
|
expect(m.visibleRange(0, 200), const PageWindow(0, 1));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('last page at the bottom', () {
|
|
|
|
|
expect(m.visibleRange(950, 50), const PageWindow(9, 9));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('clamps to valid indices at the ends', () {
|
|
|
|
|
// Overscroll below 0 (cache reaches negative) still starts at page 0.
|
|
|
|
|
expect(m.visibleRange(0, 100, cacheExtent: 500).first, 0);
|
|
|
|
|
// Overscroll past the end still ends at the last page.
|
|
|
|
|
expect(m.visibleRange(900, 200, cacheExtent: 500).last, 9);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('window entirely above content is empty', () {
|
|
|
|
|
// Scrolled far past the end with no cache reaching back.
|
|
|
|
|
expect(m.visibleRange(5000, 300), PageWindow.empty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('window entirely below content (negative, no overlap) is empty', () {
|
|
|
|
|
// viewport sitting above page 0 with bottom <= 0.
|
|
|
|
|
expect(m.visibleRange(-1000, 500), PageWindow.empty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('PageWindow helpers', () {
|
|
|
|
|
const w = PageWindow(2, 5);
|
|
|
|
|
expect(w.count, 4);
|
|
|
|
|
expect(w.contains(2), isTrue);
|
|
|
|
|
expect(w.contains(5), isTrue);
|
|
|
|
|
expect(w.contains(6), isFalse);
|
|
|
|
|
expect(PageWindow.empty.isEmpty, isTrue);
|
|
|
|
|
expect(PageWindow.empty.count, 0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-23 03:16:20 +08:00
|
|
|
group('navigation math', () {
|
|
|
|
|
final m = PageStackMetrics(pageHeights: List.filled(10, 100)); // 0..1000
|
|
|
|
|
|
|
|
|
|
test('maxScrollExtent leaves the last page bottom at the viewport bottom',
|
|
|
|
|
() {
|
|
|
|
|
expect(m.maxScrollExtent(300), 700); // 1000 - 300
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('maxScrollExtent is 0 when content is shorter than the viewport', () {
|
|
|
|
|
final short = PageStackMetrics(pageHeights: [100]);
|
|
|
|
|
expect(short.maxScrollExtent(800), 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('clampScroll keeps offset within [0, maxScrollExtent]', () {
|
|
|
|
|
expect(m.clampScroll(-50, 300), 0);
|
|
|
|
|
expect(m.clampScroll(5000, 300), 700);
|
|
|
|
|
expect(m.clampScroll(420, 300), 420);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('dominantPageAt picks the page covering most of the viewport', () {
|
|
|
|
|
// viewport [0,100) fully on page 0.
|
|
|
|
|
expect(m.dominantPageAt(0, 100), 0);
|
|
|
|
|
// viewport [180,280): page1 [100,200) covers 20, page2 [200,300) covers
|
|
|
|
|
// 80 → page 2 dominates.
|
|
|
|
|
expect(m.dominantPageAt(180, 100), 2);
|
|
|
|
|
// viewport [150,250): page1 covers [150,200)=50, page2 [200,250)=50 →
|
|
|
|
|
// tie resolves to the first (page1) since overlap must strictly exceed.
|
|
|
|
|
expect(m.dominantPageAt(150, 100), 1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('dominantPageAt clamps past the ends', () {
|
|
|
|
|
expect(m.dominantPageAt(-500, 100), 0);
|
|
|
|
|
expect(m.dominantPageAt(99999, 100), 9);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('dominantPageAt is 0 for an empty document', () {
|
|
|
|
|
final empty = PageStackMetrics(pageHeights: []);
|
|
|
|
|
expect(empty.dominantPageAt(0, 800), 0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(p0.5): continuous-single page windowing math (step 10, automatable slice)
PageStackMetrics + PageWindow: the pure geometry that decides which pages are
mounted for a scroll position (windowed lazy hosting → 60fps on a 300-page doc,
R1). Pages stack vertically with cumulative tops (O(log n) binary-search
visibleRange); a viewport [scroll, scroll+extent) grown by cacheExtent on each
side selects the inclusive intersecting page band, half-open at page boundaries,
clamped to valid indices, empty for empty/over-scrolled-past documents, and
gap-aware (a scroll resting inside an inter-page gap shows no page).
Widget-free + pdfrx-free by design: the page-mounting widget and zoom-settle DPI
refresh are device-gated; only the windowing math is automatable, and it is here
with exhaustive unit coverage (boundaries, cache band, clamping, gaps, empty).
flutter analyze lib/editor clean; 115/115 tests (+13).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 03:08:03 +08:00
|
|
|
group('visibleRange with gaps', () {
|
|
|
|
|
test('gap bands are not page bands (a scroll inside a gap shows neighbors)',
|
|
|
|
|
() {
|
|
|
|
|
final m = PageStackMetrics(pageHeights: [100, 100, 100], gap: 20);
|
|
|
|
|
// page0 [0,100), gap [100,120), page1 [120,220), gap [220,240), page2 [240,340)
|
|
|
|
|
// viewport [105,115) sits inside the first gap → nearest pages 0 and 1
|
|
|
|
|
// both within a tiny window? window [105,115): page0 bottom 100 < 105 so
|
|
|
|
|
// page0 excluded; page1 top 120 > 115 so excluded → empty band in gap.
|
|
|
|
|
expect(m.visibleRange(105, 10), PageWindow.empty);
|
|
|
|
|
// A slightly taller viewport spanning the gap catches both.
|
|
|
|
|
expect(m.visibleRange(90, 40), const PageWindow(0, 1));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|