From 22f27641caec1352352c033b68a0e4f1eee2f9cf Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 03:54:29 +0800 Subject: [PATCH] test(p0.5): direct InkPictureCache coverage (build-once + LRU) Fills the gap where the committed-ink Picture cache (the P0.5 perf primitive: record once per revision, replay every frame) was only covered indirectly via StaticInkPainter. Verifies same-(host,revision) builds exactly once then returns the cached Picture; a new revision rebuilds; host ids are independent; LRU eviction beyond maxSize keeps the most-recently-used and disposes the rest post-frame. flutter analyze lib/editor clean; 244/244 tests (+4). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/ink_picture_cache_test.dart | 79 ++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 test/ink_picture_cache_test.dart diff --git a/test/ink_picture_cache_test.dart b/test/ink_picture_cache_test.dart new file mode 100644 index 0000000..f50852b --- /dev/null +++ b/test/ink_picture_cache_test.dart @@ -0,0 +1,79 @@ +// Direct tests for InkPictureCache (the P0.5 perf primitive: a committed-ink +// ui.Picture is recorded once per revision and replayed, so pinch/pan/live-move +// frames never re-rasterize). Verifies build-once caching, revision-keyed +// rebuild, LRU eviction, and post-frame disposal. + +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/render/ink_picture_cache.dart'; + +ui.Picture _pic() { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawRect( + const Rect.fromLTWH(0, 0, 1, 1), + Paint()..color = const Color(0xFF000000), + ); + return recorder.endRecording(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const size = Size(100, 100); + + test('same (host,revision) builds once, then returns the cached Picture', () { + final cache = InkPictureCache(); + var builds = 0; + ui.Picture build() { + builds++; + return _pic(); + } + + final p1 = cache.getOrBuild('h', 3, size, build); + final p2 = cache.getOrBuild('h', 3, size, build); + expect(builds, 1, reason: 'second call is a cache hit'); + expect(identical(p1, p2), isTrue); + cache.dispose(); + }); + + test('a new revision rebuilds (revision is part of the key)', () { + final cache = InkPictureCache(); + var builds = 0; + cache.getOrBuild('h', 1, size, () { builds++; return _pic(); }); + cache.getOrBuild('h', 2, size, () { builds++; return _pic(); }); + expect(builds, 2); + cache.dispose(); + }); + + test('different host ids are independent', () { + final cache = InkPictureCache(); + var builds = 0; + cache.getOrBuild('a', 1, size, () { builds++; return _pic(); }); + cache.getOrBuild('b', 1, size, () { builds++; return _pic(); }); + expect(builds, 2); + cache.dispose(); + }); + + testWidgets('evicts + disposes the LRU beyond maxSize (post-frame)', + (tester) async { + final cache = InkPictureCache(maxSize: 2); + final first = cache.getOrBuild('a', 1, const Size(100, 100), _pic); + cache.getOrBuild('b', 1, const Size(100, 100), _pic); + // Re-touch 'a' so 'b' becomes LRU. + cache.getOrBuild('a', 1, const Size(100, 100), _pic); + // Insert a 3rd → evicts the LRU ('b'); 'a' (first) survives. + cache.getOrBuild('c', 1, const Size(100, 100), _pic); + + // 'a' is still cached → no rebuild. + var rebuilt = false; + final aAgain = cache.getOrBuild( + 'a', 1, const Size(100, 100), () { rebuilt = true; return _pic(); }); + expect(rebuilt, isFalse); + expect(identical(aAgain, first), isTrue); + + cache.dispose(); + await tester.pump(); // run the deferred disposals + }); +}