// 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 values) : median = _pct(values, 50), p95 = _pct(values, 95), worst = values.isEmpty ? 0 : (List.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 v, int p) { if (v.isEmpty) return 0; final s = List.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(); 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 = []; final raster = []; final total = []; var seen = 0; void onTimings(List 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)}';