feat(f2): two-page spread layout foundation (pair rows + row heights)
Some checks failed
CI / Windows build (push) Has been cancelled

Book-like reading the user explicitly wanted: pairIntoRows groups pages into
two-up spread rows (optional coverAlone for a title page; trailing odd page sits
alone), spreadRowHeights fits each page to half the column and takes the tallest
per row (common baseline), and spreadStackMetrics stacks the rows so the
existing PageStackMetrics.visibleRange windows continuous-DOUBLE by ROW.

Pure geometry reusing the continuous-single layer; the row-mounting widget is
device-gated. Zero-rework-risk (not rendering).

flutter analyze lib/editor clean; 172/172 tests (+8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 03:27:20 +08:00
parent 3e0b81be92
commit 1aa2865d6f
2 changed files with 171 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
// Tests for the two-up spread layout foundation (F2/F3).
import 'dart:ui' show Size;
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/layout/double_page_layout.dart';
import 'package:badnote/editor/pdf/pdf_document_source.dart';
class _Src implements PageDocumentSource {
_Src(this._sizes);
final List<Size> _sizes;
@override
int get pageCount => _sizes.length;
@override
Size pageSize(int index) => _sizes[index];
}
void main() {
group('pairIntoRows', () {
test('pairs from 0 by default', () {
expect(pairIntoRows(4), [
[0, 1],
[2, 3],
]);
});
test('trailing odd page gets a single-page row', () {
expect(pairIntoRows(5), [
[0, 1],
[2, 3],
[4],
]);
});
test('coverAlone puts page 0 alone, then pairs', () {
expect(pairIntoRows(5, coverAlone: true), [
[0],
[1, 2],
[3, 4],
]);
});
test('edge counts', () {
expect(pairIntoRows(0), isEmpty);
expect(pairIntoRows(1), [
[0],
]);
expect(pairIntoRows(1, coverAlone: true), [
[0],
]);
expect(pairIntoRows(2, coverAlone: true), [
[0],
[1],
]);
});
});
group('spreadRowHeights', () {
test('row height is the tallest page at half column width', () {
// page0 600x800 (aspect 800/600), page1 600x400. At column 600 → half 300.
// page0 fit = 300 * 800/600 = 400; page1 fit = 300 * 400/600 = 200.
final src = _Src([const Size(600, 800), const Size(600, 400)]);
final rows = pairIntoRows(2);
expect(spreadRowHeights(src, 600, rows), [400.0]); // max(400,200)
});
test('single-page row uses that page only', () {
final src = _Src([const Size(400, 800)]);
final rows = pairIntoRows(1);
// half = 150; fit = 150 * 800/400 = 300.
expect(spreadRowHeights(src, 300, rows), [300.0]);
});
test('non-positive width contributes 0', () {
final src = _Src([const Size(0, 800), const Size(400, 400)]);
final rows = pairIntoRows(2); // [[0,1]]
// page0 → 0; page1 half=150 fit=150*400/400=150 → max 150.
expect(spreadRowHeights(src, 300, rows), [150.0]);
});
});
group('spreadStackMetrics composes with windowing', () {
test('rows stack vertically; visibleRange selects visible rows', () {
// 6 portrait pages 600x800 → 3 rows; at column 600 each row 400 tall.
final src = _Src(List.filled(6, const Size(600, 800)));
final m = spreadStackMetrics(src, 600, gap: 0);
expect(m.pageCount, 3); // 3 ROWS
expect(m.totalExtent, closeTo(1200, 1e-9)); // 3 * 400
// viewport [350,850): row0 [0,400), row1 [400,800), row2 [800,1200).
// first bottom>350 → row0; last top<850 → row2.
expect(m.visibleRange(350, 500).first, 0);
expect(m.visibleRange(350, 500).last, 2);
});
});
}