halkyon/halkyon_learn_flutter/integration_test/pdf_engine_test.dart
Tizian 「ティツィアーン」 b96db731c1
Some checks failed
CI / check (push) Failing after 26s
CI / deploy (push) Has been skipped
feat: PDF sources: read, take notes and cards from pages, deadlines
PDFs live in a studyset's new Files tab: uploaded from Add material
(a file the studyset holds already asks first), each row with its
pages, cards, notes and size, how far it is read and its reading
deadline; renamed, given a deadline (a page or a chapter by a day),
marked as read, started afresh, deleted with a moment to undo.

The reader (/pdfs/:id) opens where reading left off: on wide windows
a rail of tools (search, fit, zoom, fullscreen), a page counter with
the chapter, and a sidebar; on phones a top bar, a page scrubber and
a sheet. The sidebar holds the PDF's flashcards, by page or chapter,
made from selected text or written there, each tied to its page and
tagged with its chapter; and the page's note, an ordinary note tied
to the page. Screenshots of a region become images that know their
page. Cards and screenshots lead back to their page; reading on past
a chapter with cards suggests studying it. Home shows the deadlines
running and to come, and a timeline of them.

Server: pdf_source rows (content in the payload, last writer wins)
and an append-only pdf_reading log, the furthest page derived from
it so devices never conflict. Files up to 50 MB for PDFs (20 MB for
others; a larger file that isn't a PDF is deleted once stored). A
deleted PDF takes its file's asset row along unless another PDF has
the same file. Card payload 4 and note payload 2 keep the page.

The reader draws with pdfrx (PDFium) behind PdfEngine in
Dependencies; widget tests use a fake, and
integration_test/pdf_engine_test.dart checks the real engine on a
device or the desktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-03 14:40:08 +02:00

135 lines
4.5 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdfrx_engine.dart';
import 'package:integration_test/integration_test.dart';
import 'package:material_ui/material_ui.dart';
// The app's PDF engine for real, PDFium included, which `flutter test`
// can't load: on a device or the desktop,
//
// flutter test integration_test/pdf_engine_test.dart -d linux
//
// on a PDF of three pages, "Hello page N" on each, with an outline of two
// chapters.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(PdfrxEngine.initialize);
testWidgets('opens a PDF, reads its chapters and draws a region', (
tester,
) async {
final document = await tester.runAsync(
() => PdfrxEngine().open(_threePages()),
);
addTearDown(() => tester.runAsync(document!.dispose));
expect(document!.pageCount, 3);
expect(await tester.runAsync(document.chapters), [
(title: 'One', page: 1),
(title: 'Two', page: 3),
]);
final png = await tester.runAsync(
() => document.renderRegion(1, const Rect.fromLTWH(0, 0, 0.5, 0.5)),
);
expect(png!.sublist(0, 4), [0x89, 0x50, 0x4e, 0x47]);
});
testWidgets('refuses a file that is no PDF', (tester) async {
// runAsync reports what is thrown inside instead of passing it on.
final refused = await tester.runAsync(() async {
try {
await PdfrxEngine().open(Uint8List.fromList(utf8.encode('text')));
return false;
} on FormatException {
return true;
}
});
expect(refused, isTrue);
});
testWidgets('its viewer turns pages and finds text', (tester) async {
final document = await tester.runAsync(
() => PdfrxEngine().open(_threePages()),
);
final controller = PdfReaderController();
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 600,
height: 800,
child: document!.viewer(controller),
),
),
),
);
Future<void> until(bool Function() test) async {
final waited = Stopwatch()..start();
while (!test() && waited.elapsed < const Duration(seconds: 20)) {
await tester.pump(const Duration(milliseconds: 100));
}
}
await until(() => controller.isReady);
expect(controller.isReady, isTrue);
// The page turns over an animation, whose frames the test drives: it
// is started, then pumped until it arrives.
unawaited(controller.goToPage(3));
await until(() => controller.page == 3);
expect(controller.page, 3);
controller.search('hello');
await until(() => controller.matchCount == 3);
expect(controller.matchCount, 3);
expect(controller.pageRect(3), isNotNull);
});
}
/// A PDF of three pages, each saying "Hello page N", with an outline of
/// chapter "One" at page 1 and "Two" at page 3.
Uint8List _threePages() {
String text(int page) {
final stream = 'BT /F1 24 Tf 40 700 Td (Hello page $page) Tj ET';
return '<< /Length ${stream.length} >>\nstream\n$stream\nendstream';
}
String pageOf(int page) =>
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] '
'/Contents ${5 + page} 0 R /Resources << /Font << /F1 9 0 R >> >> >>';
final objects = [
'<< /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>',
for (final page in [1, 2, 3]) pageOf(page),
for (final page in [1, 2, 3]) text(page),
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
'<< /Type /Outlines /First 11 0 R /Last 12 0 R /Count 2 >>',
'<< /Title (One) /Parent 10 0 R /Next 12 0 R /Dest [3 0 R /Fit] >>',
'<< /Title (Two) /Parent 10 0 R /Prev 11 0 R /Dest [5 0 R /Fit] >>',
];
final out = StringBuffer('%PDF-1.7\n');
final offsets = <int>[];
for (final (index, object) in objects.indexed) {
offsets.add(out.length);
out.write('${index + 1} 0 obj\n$object\nendobj\n');
}
final xref = out.length;
out
..write('xref\n0 ${objects.length + 1}\n0000000000 65535 f \n')
..writeAll([
for (final offset in offsets)
'${offset.toString().padLeft(10, '0')} 00000 n \n',
])
..write(
'trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n'
'startxref\n$xref\n%%EOF\n',
);
return Uint8List.fromList(latin1.encode(out.toString()));
}