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 + }); +}