// Unit tests for the DPI-bucketed page-tile cache (P0.5 step 10, automatable // slice). Covers LRU eviction, MRU promotion, per-key replacement, host // eviction, dpiBucketFor snapping, and post-frame disposal of evicted images. // // The pdfrx tile RENDERING (page_tile.dart) is device-gated and not tested // here; this is the pure cache data structure. import 'dart:ui' as ui; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:badnote/editor/pdf/page_tile_cache.dart'; Future _img() async { final recorder = ui.PictureRecorder(); Canvas(recorder).drawRect( const Rect.fromLTWH(0, 0, 2, 2), Paint()..color = const Color(0xFF000000), ); final picture = recorder.endRecording(); final image = await picture.toImage(2, 2); picture.dispose(); return image; } void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('dpiBucketFor', () { test('snaps continuous scale to a coarse bucket (ceil by step)', () { expect(dpiBucketFor(1.0, step: 0.5), 2); // 1.0/0.5 = 2 expect(dpiBucketFor(1.1, step: 0.5), 3); // ceil(2.2) expect(dpiBucketFor(0.4, step: 0.5), 1); // ceil(0.8) = 1 }); test('is monotonic non-decreasing in scale', () { var prev = 0; for (final s in [0.3, 0.6, 1.0, 1.6, 2.0, 2.9]) { final b = dpiBucketFor(s, step: 0.5, maxBucket: 100); expect(b, greaterThanOrEqualTo(prev)); prev = b; } }); test('caps at maxBucket (bounds retained-DPI memory)', () { expect(dpiBucketFor(99.0, step: 0.5, maxBucket: 6), 6); }); test('guards invalid scale', () { expect(dpiBucketFor(0), 1); expect(dpiBucketFor(-3), 1); expect(dpiBucketFor(double.nan), 1); }); }); group('PageTileCache LRU', () { test('get returns null on miss, the image on hit', () async { final cache = PageTileCache(maxTiles: 4); const key = TileKey('p0', 2); expect(cache.get(key), isNull); final img = await _img(); cache.put(key, img); expect(identical(cache.get(key), img), isTrue); cache.dispose(); }); test('TileKey equality is by (hostId, dpiBucket)', () { expect(const TileKey('p0', 2), const TileKey('p0', 2)); expect(const TileKey('p0', 2), isNot(const TileKey('p0', 3))); expect(const TileKey('p0', 2), isNot(const TileKey('p1', 2))); expect(const TileKey('p0', 2).hashCode, const TileKey('p0', 2).hashCode); }); test('evicts the least-recently-used beyond the cap', () async { final cache = PageTileCache(maxTiles: 2); cache.put(const TileKey('a', 1), await _img()); cache.put(const TileKey('b', 1), await _img()); cache.put(const TileKey('c', 1), await _img()); // evicts 'a' expect(cache.length, 2); expect(cache.keys, isNot(contains(const TileKey('a', 1)))); expect(cache.get(const TileKey('a', 1)), isNull); expect(cache.get(const TileKey('b', 1)), isNotNull); cache.dispose(); }); test('get promotes MRU so the OTHER entry is evicted next', () async { final cache = PageTileCache(maxTiles: 2); cache.put(const TileKey('a', 1), await _img()); cache.put(const TileKey('b', 1), await _img()); cache.get(const TileKey('a', 1)); // 'a' now MRU → 'b' is LRU cache.put(const TileKey('c', 1), await _img()); // evicts 'b' expect(cache.get(const TileKey('a', 1)), isNotNull); expect(cache.get(const TileKey('b', 1)), isNull); cache.dispose(); }); test('evictHostsExcept drops other hosts, keeps live ones', () async { final cache = PageTileCache(maxTiles: 8); cache.put(const TileKey('p0', 1), await _img()); cache.put(const TileKey('p0', 2), await _img()); cache.put(const TileKey('p1', 1), await _img()); cache.put(const TileKey('p2', 1), await _img()); cache.evictHostsExcept({'p0', 'p1'}); expect(cache.keys.map((k) => k.hostId).toSet(), {'p0', 'p1'}); expect(cache.length, 3); cache.dispose(); }); }); group('PageTileCache disposal (post-frame)', () { testWidgets('an evicted tile is disposed after the frame', (tester) async { final cache = PageTileCache(maxTiles: 1); final first = await _img(); cache.put(const TileKey('a', 1), first); cache.put(const TileKey('b', 1), await _img()); // evicts 'a' → defers expect(first.debugDisposed, isFalse, reason: 'deferred, not yet'); await tester.pump(); // run the post-frame callback expect(first.debugDisposed, isTrue); cache.dispose(); await tester.pump(); }); testWidgets('re-putting a different image for a key disposes the old one', (tester) async { final cache = PageTileCache(maxTiles: 4); final old = await _img(); cache.put(const TileKey('a', 1), old); cache.put(const TileKey('a', 1), await _img()); await tester.pump(); expect(old.debugDisposed, isTrue); expect(cache.length, 1); cache.dispose(); await tester.pump(); }); testWidgets('dispose() frees all retained tiles', (tester) async { final cache = PageTileCache(maxTiles: 8); final a = await _img(); final b = await _img(); cache.put(const TileKey('a', 1), a); cache.put(const TileKey('b', 1), b); cache.dispose(); await tester.pump(); expect(a.debugDisposed, isTrue); expect(b.debugDisposed, isTrue); expect(cache.length, 0); }); }); }