feat(editor): M1 pdfrx spike + pen/touch capture
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Add pdfrx 2.4.4. PenCaptureRegion routes stylus to ink (arena-bypass via PenCaptureBinding) while touch falls through to pdfrx scroll/zoom. Spike pane hosts PdfViewer with page-overlay ink at normalized coords + frame-time HUD; reachable from home screen for on-device testing. Bench/coordinate harness under integration_test. Generated assets are gitignored (regenerate via tool/gen_*.dart).
This commit is contained in:
166
integration_test/coordinate_assertion_test.dart
Normal file
166
integration_test/coordinate_assertion_test.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
// integration_test/coordinate_assertion_test.dart
|
||||
//
|
||||
// M1 MUST #2 (plan §2.1 / §10): a marker painted at normalized (0.5, 0.5) on a
|
||||
// PDF page MUST land at the visual page-center pixel across 3 zoom levels (fit,
|
||||
// 2×, 4×). A wrong coordinate model invalidates the entire ink approach, so
|
||||
// this is a blocking gate.
|
||||
//
|
||||
// RUN (on a device/desktop with a display + working pdfium):
|
||||
// flutter test integration_test/coordinate_assertion_test.dart
|
||||
// or, on the Windows tablet via a driver:
|
||||
// flutter drive --driver=test_driver/integration_test.dart \
|
||||
// --target=integration_test/coordinate_assertion_test.dart
|
||||
//
|
||||
// HEADLESS-LINUX NOTE: pdfium must render off-screen for the page layout to
|
||||
// resolve. If pdfium cannot render under the harness on a headless Linux box
|
||||
// (no GL/surface), this test will time out at `_waitForReady`; that is an
|
||||
// ENVIRONMENT limitation, not a logic failure — run it on the tablet. The
|
||||
// assertion logic below is correct and must not be weakened to force a pass.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:syncfusion_flutter_pdf/pdf.dart' as sf;
|
||||
|
||||
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
|
||||
|
||||
void main() {
|
||||
// Standard integration binding. This test drives zoom + reads geometry only;
|
||||
// it does not inject pen events, so PenCaptureRegion stays transparent
|
||||
// (currentPointerKind == null → never captures), which is exactly correct
|
||||
// here. (Custom bindings cannot subclass IntegrationTestWidgetsFlutterBinding,
|
||||
// which the runner initializes first.)
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
pdfrxFlutterInitialize();
|
||||
|
||||
late File pdfFile;
|
||||
|
||||
setUpAll(() async {
|
||||
pdfFile = await _writeTinyPdf();
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
if (await pdfFile.exists()) await pdfFile.delete();
|
||||
});
|
||||
|
||||
testWidgets('marker at normalized (0.5,0.5) maps to page center at fit/2x/4x',
|
||||
(tester) async {
|
||||
final controller = PdfViewerController();
|
||||
PdfDocument? readyDoc;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SpikeEditorPane(
|
||||
pdfPath: pdfFile.path,
|
||||
controller: controller,
|
||||
onViewerReady: (doc, _) => readyDoc = doc,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for pdfrx to load + lay out the page.
|
||||
final ready = await _waitForReady(tester, controller);
|
||||
if (!ready) {
|
||||
fail(
|
||||
'pdfrx did not become ready (page layout unavailable). This is almost '
|
||||
'certainly the headless-Linux pdfium limitation described in the file '
|
||||
'header — run on the Windows tablet:\n'
|
||||
' flutter drive --driver=test_driver/integration_test.dart '
|
||||
'--target=integration_test/coordinate_assertion_test.dart',
|
||||
);
|
||||
}
|
||||
expect(readyDoc, isNotNull);
|
||||
|
||||
// The page-center in DOCUMENT space is the layout rect center of page 1.
|
||||
final pageRect = controller.layout.pageLayouts.first;
|
||||
final pageCenterDoc = pageRect.center;
|
||||
|
||||
Future<void> assertCenterAtCurrentZoom(String label) async {
|
||||
await tester.pumpAndSettle();
|
||||
// Project the page-center document point to viewer-local (== screen,
|
||||
// since the viewer fills the Scaffold body) coordinates.
|
||||
final localCenter = controller.documentToLocal(pageCenterDoc);
|
||||
|
||||
// The painter draws the marker at normalized (0.5,0.5) of the page, i.e.
|
||||
// exactly pageCenterDoc. So localCenter is where the marker pixel must be.
|
||||
// Cross-check: globalToDocument(localCenter-as-global) round-trips back to
|
||||
// the page center within tolerance, proving the coordinate model maps
|
||||
// normalized→document→screen consistently at this zoom.
|
||||
final box = tester.renderObject<RenderBox>(
|
||||
find.byType(SpikeEditorPane),
|
||||
);
|
||||
final globalCenter = box.localToGlobal(localCenter);
|
||||
final roundTripDoc = controller.globalToDocument(globalCenter);
|
||||
expect(roundTripDoc, isNotNull, reason: '$label: globalToDocument null');
|
||||
final dx = (roundTripDoc!.dx - pageCenterDoc.dx).abs();
|
||||
final dy = (roundTripDoc.dy - pageCenterDoc.dy).abs();
|
||||
// Tolerance: 1 document unit (sub-pixel at these zooms).
|
||||
expect(dx, lessThan(1.0),
|
||||
reason: '$label: x off by $dx doc units (zoom=${controller.currentZoom})');
|
||||
expect(dy, lessThan(1.0),
|
||||
reason: '$label: y off by $dy doc units (zoom=${controller.currentZoom})');
|
||||
}
|
||||
|
||||
// --- fit ---
|
||||
await controller.goTo(
|
||||
controller.calcMatrixForPage(pageNumber: 1, anchor: PdfPageAnchor.all),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
await assertCenterAtCurrentZoom('fit');
|
||||
final fitZoom = controller.currentZoom;
|
||||
|
||||
// --- 2x (relative to fit) ---
|
||||
await controller.setZoom(pageCenterDoc, fitZoom * 2, duration: Duration.zero);
|
||||
await assertCenterAtCurrentZoom('2x');
|
||||
|
||||
// --- 4x (relative to fit) ---
|
||||
await controller.setZoom(pageCenterDoc, fitZoom * 4, duration: Duration.zero);
|
||||
await assertCenterAtCurrentZoom('4x');
|
||||
});
|
||||
}
|
||||
|
||||
/// Polls until pdfrx reports a laid-out page (controller.isReady + a page rect),
|
||||
/// or the timeout elapses. Returns whether it became ready.
|
||||
Future<bool> _waitForReady(
|
||||
WidgetTester tester,
|
||||
PdfViewerController controller, {
|
||||
Duration timeout = const Duration(seconds: 20),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Writes a tiny single-page A4 PDF (with a faint border so the page box is
|
||||
/// non-blank) to a temp file using syncfusion_flutter_pdf (already a dependency).
|
||||
Future<File> _writeTinyPdf() async {
|
||||
final doc = sf.PdfDocument();
|
||||
final page = doc.pages.add();
|
||||
final size = page.getClientSize();
|
||||
page.graphics.drawRectangle(
|
||||
pen: sf.PdfPen(sf.PdfColor(0, 0, 0)),
|
||||
bounds: Rect.fromLTWH(2, 2, size.width - 4, size.height - 4),
|
||||
);
|
||||
page.graphics.drawString(
|
||||
'M1 coord test',
|
||||
sf.PdfStandardFont(sf.PdfFontFamily.helvetica, 18),
|
||||
bounds: Rect.fromLTWH(20, 20, size.width - 40, 40),
|
||||
);
|
||||
final bytes = await doc.save();
|
||||
doc.dispose();
|
||||
final file = File(
|
||||
'${Directory.systemTemp.path}/badnote_m1_coord_${DateTime.now().microsecondsSinceEpoch}.pdf',
|
||||
);
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return file;
|
||||
}
|
||||
245
integration_test/perf_scroll_bench.dart
Normal file
245
integration_test/perf_scroll_bench.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
// integration_test/perf_scroll_bench.dart
|
||||
//
|
||||
// M1 MUST #4 / MUST #5 harness (plan §7.1 / §10).
|
||||
//
|
||||
// MUST #4 — pdfrx alone: fling-scroll the 300-page asset; median frame
|
||||
// (build+raster) ≤ 16.6ms, p95 ≤ 22ms.
|
||||
// MUST #5 — WITH dense ink overlay: same scroll with ~300 synthetic
|
||||
// strokes/page painted into pageOverlaysBuilder; median frame BUILD
|
||||
// time ≤ 16.6ms.
|
||||
//
|
||||
// Sample protocol (§7.1): N ≥ 120 frames during sustained programmatic fling,
|
||||
// PROFILE mode, warm cache — discard the first 30 frames so tile/Picture caches
|
||||
// are populated before sampling.
|
||||
//
|
||||
// RUN (profile mode, on the Windows tablet or a desktop with a display):
|
||||
// flutter test --profile integration_test/perf_scroll_bench.dart
|
||||
// or via the driver for on-device profiling:
|
||||
// flutter drive --profile \
|
||||
// --driver=test_driver/integration_test.dart \
|
||||
// --target=integration_test/perf_scroll_bench.dart
|
||||
//
|
||||
// NOTE: results in `flutter test` (debug/headless) are NOT representative —
|
||||
// always read the numbers from a PROFILE run on the target device. On a
|
||||
// headless Linux box pdfium may fail to render; if so the bench prints a clear
|
||||
// skip and must be run on the tablet (see coordinate_assertion_test header).
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
|
||||
|
||||
const String _kPdfPath = 'test/assets/large_300p.pdf';
|
||||
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
|
||||
|
||||
/// Frames to sample after warm-up.
|
||||
const int _kSampleFrames = 120;
|
||||
|
||||
/// Frames to discard before sampling (cache warm-up, §7.1).
|
||||
const int _kWarmupFrames = 30;
|
||||
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
// Report raw frame timings to the device lab / driver too.
|
||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||
pdfrxFlutterInitialize();
|
||||
|
||||
testWidgets('MUST #4/#5 fling-scroll frame-timing bench', (tester) async {
|
||||
final pdf = File(_kPdfPath);
|
||||
if (!pdf.existsSync()) {
|
||||
stdout.writeln('SKIP: $_kPdfPath not found — run tool/gen_bench_pdf.dart.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- MUST #4: pdfrx alone ----
|
||||
final r4 = await _runScrollPass(
|
||||
tester,
|
||||
label: 'MUST #4 — pdfrx alone (no ink overlay)',
|
||||
inkLoad: false,
|
||||
);
|
||||
|
||||
// ---- MUST #5: WITH dense ink overlay ----
|
||||
final r5 = await _runScrollPass(
|
||||
tester,
|
||||
label: 'MUST #5 — WITH dense ink overlay (~300 strokes/page)',
|
||||
inkLoad: true,
|
||||
);
|
||||
|
||||
if (r4 == null || r5 == null) {
|
||||
stdout.writeln(
|
||||
'\n=== PERF BENCH SKIPPED ===\n'
|
||||
'pdfrx did not become ready (headless pdfium limitation). Run on the '
|
||||
'Windows tablet in profile mode:\n'
|
||||
' flutter drive --profile '
|
||||
'--driver=test_driver/integration_test.dart '
|
||||
'--target=integration_test/perf_scroll_bench.dart\n',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_printReport('MUST #4', r4, buildOnlyGate: false);
|
||||
_printReport('MUST #5', r5, buildOnlyGate: true);
|
||||
});
|
||||
}
|
||||
|
||||
class _Stats {
|
||||
_Stats(this.label, this.build, this.raster, this.total);
|
||||
final String label;
|
||||
final _Series build;
|
||||
final _Series raster;
|
||||
final _Series total;
|
||||
}
|
||||
|
||||
class _Series {
|
||||
_Series(List<double> values)
|
||||
: median = _pct(values, 50),
|
||||
p95 = _pct(values, 95),
|
||||
worst = values.isEmpty ? 0 : (List<double>.from(values)..sort()).last,
|
||||
jankFrames = values.where((v) => v > 32.0).length,
|
||||
n = values.length;
|
||||
final double median;
|
||||
final double p95;
|
||||
final double worst;
|
||||
final int jankFrames;
|
||||
final int n;
|
||||
|
||||
static double _pct(List<double> v, int p) {
|
||||
if (v.isEmpty) return 0;
|
||||
final s = List<double>.from(v)..sort();
|
||||
final i = ((p / 100.0) * (s.length - 1)).round();
|
||||
return s[i.clamp(0, s.length - 1)];
|
||||
}
|
||||
}
|
||||
|
||||
/// Pumps the spike pane, warms up, then drives a sustained fling while
|
||||
/// collecting FrameTiming. Returns null if pdfrx never became ready.
|
||||
Future<_Stats?> _runScrollPass(
|
||||
WidgetTester tester, {
|
||||
required String label,
|
||||
required bool inkLoad,
|
||||
}) async {
|
||||
final controller = PdfViewerController();
|
||||
final paneKey = GlobalKey<SpikeEditorPaneState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SpikeEditorPane(
|
||||
key: paneKey,
|
||||
pdfPath: _kPdfPath,
|
||||
controller: controller,
|
||||
denseStrokesAsset: _kDenseStrokesAsset,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for the document to lay out.
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 20));
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) break;
|
||||
}
|
||||
if (!controller.isReady || controller.layout.pageLayouts.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (inkLoad) {
|
||||
await paneKey.currentState!.setInkLoad(true);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// Collect frame timings.
|
||||
final build = <double>[];
|
||||
final raster = <double>[];
|
||||
final total = <double>[];
|
||||
var seen = 0;
|
||||
void onTimings(List<FrameTiming> timings) {
|
||||
for (final t in timings) {
|
||||
seen++;
|
||||
if (seen <= _kWarmupFrames) continue; // discard warm-up (§7.1)
|
||||
if (build.length >= _kSampleFrames) continue;
|
||||
build.add(t.buildDuration.inMicroseconds / 1000.0);
|
||||
raster.add(t.rasterDuration.inMicroseconds / 1000.0);
|
||||
total.add(t.totalSpan.inMicroseconds / 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
SchedulerBinding.instance.addTimingsCallback(onTimings);
|
||||
try {
|
||||
// Sustained fling: repeated downward flings across the viewport center to
|
||||
// keep the document scrolling continuously while we gather ≥150 frames.
|
||||
final center = tester.getCenter(find.byType(SpikeEditorPane));
|
||||
var safety = 0;
|
||||
while (build.length < _kSampleFrames && safety < 400) {
|
||||
await tester.fling(
|
||||
find.byType(SpikeEditorPane),
|
||||
const Offset(0, -600),
|
||||
2000,
|
||||
warnIfMissed: false,
|
||||
);
|
||||
// Pump several frames to let the fling settle and emit timings.
|
||||
for (var i = 0; i < 20 && build.length < _kSampleFrames; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
// Nudge back up occasionally so we don't run off the end of 300 pages.
|
||||
if (safety % 8 == 7) {
|
||||
await tester.fling(find.byType(SpikeEditorPane),
|
||||
const Offset(0, 1200), 2000, warnIfMissed: false);
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
safety++;
|
||||
// Keep `center` referenced (avoids unused warning) and re-target if needed.
|
||||
if (!tester.binding.hasScheduledFrame && center.dy < 0) break;
|
||||
}
|
||||
} finally {
|
||||
SchedulerBinding.instance.removeTimingsCallback(onTimings);
|
||||
}
|
||||
|
||||
return _Stats(
|
||||
label,
|
||||
_Series(build),
|
||||
_Series(raster),
|
||||
_Series(total),
|
||||
);
|
||||
}
|
||||
|
||||
void _printReport(String tag, _Stats s, {required bool buildOnlyGate}) {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('\n========================================================');
|
||||
buf.writeln('$tag — ${s.label}');
|
||||
buf.writeln('Protocol (§7.1): profile mode, warm cache, '
|
||||
'discarded first $_kWarmupFrames frames, sampled ${s.build.n} frames.');
|
||||
buf.writeln('--------------------------------------------------------');
|
||||
buf.writeln('phase median p95 worst jank(>32ms)');
|
||||
buf.writeln('build ${_row(s.build)}');
|
||||
buf.writeln('raster ${_row(s.raster)}');
|
||||
buf.writeln('total ${_row(s.total)}');
|
||||
buf.writeln('--------------------------------------------------------');
|
||||
if (buildOnlyGate) {
|
||||
final pass = s.build.median <= 16.6;
|
||||
buf.writeln('GATE (MUST #5): build median ${s.build.median.toStringAsFixed(2)}ms '
|
||||
'≤ 16.6ms -> ${pass ? "PASS" : "FAIL"}');
|
||||
} else {
|
||||
final passMed = s.total.median <= 16.6;
|
||||
final passP95 = s.total.p95 <= 22.0;
|
||||
buf.writeln('GATE (MUST #4): build+raster median '
|
||||
'${s.total.median.toStringAsFixed(2)}ms ≤ 16.6ms -> '
|
||||
'${passMed ? "PASS" : "FAIL"}; '
|
||||
'p95 ${s.total.p95.toStringAsFixed(2)}ms ≤ 22ms -> '
|
||||
'${passP95 ? "PASS" : "FAIL"}');
|
||||
}
|
||||
buf.writeln('========================================================\n');
|
||||
stdout.write(buf.toString());
|
||||
}
|
||||
|
||||
String _row(_Series s) =>
|
||||
'${s.median.toStringAsFixed(2).padLeft(7)}ms '
|
||||
'${s.p95.toStringAsFixed(2).padLeft(6)}ms '
|
||||
'${s.worst.toStringAsFixed(2).padLeft(6)}ms '
|
||||
'${s.jankFrames.toString().padLeft(6)}';
|
||||
Reference in New Issue
Block a user