diff --git a/integration_test/library_paging_performance_test.dart b/integration_test/library_paging_performance_test.dart new file mode 100644 index 00000000..c60d3454 --- /dev/null +++ b/integration_test/library_paging_performance_test.dart @@ -0,0 +1,348 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/providers/library_browse_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/screens/mornye_library_screen.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +import '../test/support/library_paging_fixture.dart'; +import 'performance_probe.dart'; + +const _request = ( + artists: false, + artist: null, + search: '', + sort: 'a-z', + limit: 40, + completeness: null, +); + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(localLibraryEnabled: true); +} + +class _History extends DownloadHistoryNotifier { + @override + DownloadHistoryState build() => DownloadHistoryState(); +} + +class _Local extends LocalLibraryNotifier { + @override + LocalLibraryState build() => LocalLibraryState(); +} + +/// The previous growing-prefix algorithm, scoped to this fixture. The same +/// production screen and SQL read adapter render both sides of the comparison. +class _PrefixBrowse extends LibraryBrowseNotifier { + _PrefixBrowse(super.request, this.readPage); + + final Future Function(LibraryBrowsePageRequest) readPage; + late int _limit; + + Future _read() async { + final page = await readPage(( + browse: ( + artists: request.artists, + artist: request.artist, + search: request.search, + sort: request.sort, + limit: _limit, + completeness: request.completeness, + ), + offset: 0, + cursor: null, + includeLocal: true, + )); + return LibraryBrowseState( + page.entries, + hasMore: page.entries.length >= _limit, + ); + } + + @override + Future build() { + _limit = request.limit; + return _read(); + } + + @override + Future loadMore() async { + if (state.isLoading || !state.hasValue) return; + final previous = state.requireValue; + if (previous.isLoadingMore || !previous.hasMore) return; + state = AsyncData( + LibraryBrowseState(previous.entries, hasMore: true, isLoadingMore: true), + ); + _limit += request.limit; + state = AsyncData(await _read()); + } +} + +Future _cover(String path) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawColor(const Color(0xff305060), BlendMode.src); + canvas.drawCircle( + const Offset(160, 180), + 125, + Paint()..color = const Color(0xffc47f56), + ); + canvas.drawRect( + const Rect.fromLTWH(40, 245, 300, 40), + Paint()..color = const Color(0xffc3e1dc), + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(384, 384); + try { + final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!; + await File(path).writeAsBytes(bytes.buffer.asUint8List()); + } finally { + image.dispose(); + picture.dispose(); + } +} + +Future _pixels(GlobalKey key, double ratio) async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: ratio); + try { + final bytes = (await image.toByteData(format: ui.ImageByteFormat.rawRgba))!; + return Uint8List.fromList(bytes.buffer.asUint8List()); + } finally { + image.dispose(); + } +} + +void main() { + final binding = PerformanceTestBinding.ensureInitialized(); + testWidgets('paired actual SQLite long-scroll Library paging', ( + tester, + ) async { + final probe = PerformanceProbe(binding, tester, suite: 'library_paging'); + final directory = await Directory.systemTemp.createTemp('library-paging-'); + final coverPath = '${directory.path}/cover.png'; + await _cover(coverPath); + final db = await openDatabase( + '${directory.path}/fixture.db', + singleInstance: false, + ); + addTearDown(() async { + await db.close(); + await directory.delete(recursive: true); + }); + final store = await createLibraryPagingFixture(db, cover: coverPath); + final key = GlobalKey(); + var mountNumber = 0; + var returnedRows = 0; + var queryCount = 0; + var queryMicroseconds = 0; + final runCounters = >[]; + final pixelChecks = >[]; + + Future readPage(LibraryBrowsePageRequest request) async { + final watch = Stopwatch()..start(); + final page = await readLibraryPagingFixture(store, request); + watch.stop(); + queryMicroseconds += watch.elapsedMicroseconds; + returnedRows += page.entries.length; + queryCount++; + return page; + } + + ProviderContainer container() => ProviderScope.containerOf( + tester.element(find.byType(MornyeLibraryScreen)), + ); + LibraryBrowseState? current() => + container().read(libraryBrowseProvider(_request)).value; + Future ready(int expected) async { + final watch = Stopwatch()..start(); + while (watch.elapsed < const Duration(seconds: 20)) { + final value = current(); + if (value != null && + !value.isLoadingMore && + value.entries.length >= expected) { + expect(value.loadMoreError, isNull); + return; + } + await probe.wait(const Duration(milliseconds: 10)); + } + fail( + 'Library did not reach $expected entries: ${current()?.entries.length}', + ); + } + + Future painted() async { + binding.scheduleFrame(); + await binding.endOfFrame; + } + + Future mount(bool before, Brightness brightness) async { + binding.attachRootWidget( + binding.wrapWithDefaultView( + RepaintBoundary( + key: key, + child: ProviderScope( + key: ValueKey(mountNumber++), + overrides: [ + settingsProvider.overrideWith(_Settings.new), + downloadHistoryProvider.overrideWith(_History.new), + localLibraryProvider.overrideWith(_Local.new), + libraryBrowsePageLoaderProvider.overrideWithValue(readPage), + if (before) + libraryBrowseProvider.overrideWith2( + (request) => _PrefixBrowse(request, readPage), + ), + ], + child: MaterialApp( + theme: MornyeTheme.build(brightness), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MornyeLibraryScreen( + page: MornyeLibraryPage.albums, + onOpenSection: (_) {}, + ), + ), + ), + ), + ), + ), + ); + await painted(); + await ready(40); + await painted(); + } + + ScrollController scroll() => tester + .widget(find.byType(CustomScrollView)) + .controller!; + + Future nextPage() async { + final expected = current()!.entries.length + 40; + scroll().jumpTo(scroll().position.maxScrollExtent); + await ready(expected); + await painted(); + } + + // First and deeper pages must remain exactly identical in both themes. + for (final brightness in Brightness.values) { + for (final pages in [1, 3]) { + late Uint8List beforePixels; + for (final before in [true, false]) { + await tester.runAsync(() async { + await mount(before, brightness); + for (var page = 1; page < pages; page++) { + await nextPage(); + } + scroll().jumpTo(0); + await probe.wait(const Duration(milliseconds: 700)); + binding.scheduleFrame(); + await binding.endOfFrame; + binding.scheduleFrame(); + await binding.endOfFrame; + final rgba = await _pixels(key, tester.view.devicePixelRatio); + if (before) { + beforePixels = rgba; + } else { + var differences = 0; + expect(rgba.length, beforePixels.length); + for (var channel = 0; channel < rgba.length; channel++) { + if (rgba[channel] != beforePixels[channel]) differences++; + } + pixelChecks.add({ + 'brightness': brightness.name, + 'pages': pages, + 'different_rgba_channels': differences, + }); + expect( + differences, + 0, + reason: 'Library paging must preserve pixels', + ); + } + }); + } + } + } + + Future longScroll(bool before, int pair) async { + returnedRows = queryCount = queryMicroseconds = 0; + await mount(before, Brightness.dark); + for (var page = 1; page < 25; page++) { + await nextPage(); + } + final entries = current()!.entries; + final expected = await readLibraryPagingFixture(store, ( + browse: ( + artists: false, + artist: null, + search: '', + sort: 'a-z', + limit: 1000, + completeness: null, + ), + offset: 0, + cursor: null, + includeLocal: true, + )); + expect( + libraryPagingIdentities(entries), + libraryPagingIdentities(expected.entries), + ); + expect(returnedRows, before ? 13000 : 1000); + expect(queryCount, 25); + runCounters.add({ + 'pair': pair, + 'mode': before ? 'prefix_before' : 'pages_after', + 'returned_rows': returnedRows, + 'queries': queryCount, + 'query_and_adapter_ms': queryMicroseconds / 1000, + }); + } + + for (var pair = 0; pair < 4; pair++) { + for (final before in pair.isEven ? [true, false] : [false, true]) { + await probe.measure( + '${before ? 'prefix_before' : 'pages_after'}_pair_${pair + 1}', + () => longScroll(before, pair + 1), + repetitions: 1, + warmup: () async { + await mount(before, Brightness.dark); + await nextPage(); + }, + ); + } + } + await tester.pumpWidget(const SizedBox.shrink()); + await probe.finish( + metadata: { + 'fixture': + 'Isolated actual SQLite schema17, 1000 two-source albums/2000 tracks, ' + '50 mirrored tracks and one unavailable source; shared decoded 384px PNG, no network.', + 'baseline': + 'Previous growing-prefix reads through production SQL; same MornyeLibraryScreen.', + 'paging': + 'Production AsyncNotifier and bounded page adapter; same SQL/store and UI.', + 'runs': runCounters, + 'pixel_checks': pixelChecks, + 'paired_order': 'AB/BA/AB/BA', + 'known_total_fixture': + '25 pages, stops at 1000 entries without an additional end-of-list probe', + }, + ); + }); +} diff --git a/integration_test/mornye_screens_performance_test.dart b/integration_test/mornye_screens_performance_test.dart index 356b8ddd..32e0c493 100644 --- a/integration_test/mornye_screens_performance_test.dart +++ b/integration_test/mornye_screens_performance_test.dart @@ -84,6 +84,7 @@ void main() { final index = ValueNotifier(0); addTearDown(index.dispose); final requests = []; + final pageOffsets = []; final search = _Search(); await tester.pumpWidget( ProviderScope( @@ -101,8 +102,10 @@ void main() { trackProvider.overrideWith(() => search), userProfileProvider.overrideWith(_Profile.new), currentMediaItemProvider.overrideWith((ref) => Stream.value(null)), - libraryBrowseProvider.overrideWith((ref, request) async { + libraryBrowsePageLoaderProvider.overrideWithValue((page) async { + final request = page.browse; requests.add(request); + pageOffsets.add(page.offset); final rows = _albums.where((album) { return '${album.name} ${album.artist}'.toLowerCase().contains( request.search.toLowerCase(), @@ -114,9 +117,13 @@ void main() { : 'unknown')); }).toList(); if (request.sort == 'latest') { - return rows.reversed.take(request.limit).toList(); + return LibraryBrowsePage( + rows.reversed.skip(page.offset).take(request.limit).toList(), + ); } - return rows.take(request.limit).toList(); + return LibraryBrowsePage( + rows.skip(page.offset).take(request.limit).toList(), + ); }), ], child: MaterialApp( @@ -167,7 +174,8 @@ void main() { await probe.wait(const Duration(milliseconds: 250)); } position.jumpTo(0); - expect(requests.last.limit, 200); + expect(requests.last.limit, 40); + expect(pageOffsets.last, 160); }); await probe.measure( 'library_album_grid_scroll_2000_tracks', diff --git a/lib/providers/library_browse_provider.dart b/lib/providers/library_browse_provider.dart index 056a81ce..d82df08d 100644 --- a/lib/providers/library_browse_provider.dart +++ b/lib/providers/library_browse_provider.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; @@ -56,33 +58,167 @@ class LibraryBrowseEntry { } } -final libraryBrowseProvider = FutureProvider.autoDispose - .family, LibraryBrowseRequest>(( - ref, - request, - ) async { - final includeLocal = ref.watch( - settingsProvider.select((s) => s.localLibraryEnabled), - ); - ref.watch(downloadHistoryProvider.select((s) => s.loadedIndexVersion)); - if (includeLocal) { - ref.watch(localLibraryProvider.select((s) => s.loadedIndexVersion)); +typedef LibraryBrowsePageRequest = ({ + LibraryBrowseRequest browse, + int offset, + QueueLibraryDbCursor? cursor, + bool includeLocal, +}); + +class LibraryBrowsePage { + final List entries; + final QueueLibraryDbCursor? nextCursor; + + const LibraryBrowsePage(this.entries, {this.nextCursor}); + + factory LibraryBrowsePage.fromRows( + List> rows, { + required bool artists, + QueueLibraryDbCursor? nextCursor, + }) => LibraryBrowsePage( + rows + .map((row) => LibraryBrowseEntry.fromRow(row, isArtist: artists)) + .toList(growable: false), + nextCursor: nextCursor, + ); +} + +class LibraryBrowseState { + final List entries; + final bool hasMore; + final bool isLoadingMore; + final Object? loadMoreError; + + const LibraryBrowseState( + this.entries, { + required this.hasMore, + this.isLoadingMore = false, + this.loadMoreError, + }); +} + +QueueLibraryDbQuery libraryBrowseQuery(LibraryBrowsePageRequest request) => + QueueLibraryDbQuery( + limit: request.browse.limit, + offset: request.offset, + cursor: request.cursor, + includeSingleTrackAlbums: true, + albumArtist: request.browse.artist, + searchQuery: request.browse.search, + sortMode: request.browse.sort, + includeLocal: request.includeLocal, + metadata: request.browse.completeness, + ); + +/// One bounded read; callers retain earlier pages instead of querying a prefix. +final libraryBrowsePageLoaderProvider = + Provider Function(LibraryBrowsePageRequest)>( + (ref) => (request) async { + final query = libraryBrowseQuery(request); + if (request.browse.artists) { + return LibraryBrowsePage.fromRows( + await LibraryDatabase.instance.getQueueArtistPage(query), + artists: true, + ); + } + final page = await LibraryDatabase.instance.getQueueAlbumPageResult( + query, + ); + return LibraryBrowsePage.fromRows( + page.rows, + artists: false, + nextCursor: page.nextCursor, + ); + }, + ); + +final libraryBrowseProvider = AsyncNotifierProvider.autoDispose + .family( + LibraryBrowseNotifier.new, + ); + +class LibraryBrowseNotifier extends AsyncNotifier { + LibraryBrowseNotifier(this.request); + + final LibraryBrowseRequest request; + List _rows = const []; + QueueLibraryDbCursor? _cursor; + bool _hasMore = false; + bool _loadingMore = false; + bool _includeLocal = true; + int _generation = 0; + + bool get hasMore => _hasMore; + + LibraryBrowsePageRequest _pageRequest( + int offset, + QueueLibraryDbCursor? cursor, + ) => ( + browse: request, + offset: offset, + cursor: request.artists ? null : cursor, + includeLocal: _includeLocal, + ); + + @override + Future build() async { + assert(request.limit > 0); + _includeLocal = ref.watch( + settingsProvider.select((s) => s.localLibraryEnabled), + ); + ref.watch(downloadHistoryProvider.select((s) => s.loadedIndexVersion)); + if (_includeLocal) { + ref.watch(localLibraryProvider.select((s) => s.loadedIndexVersion)); + } + final loadPage = ref.watch(libraryBrowsePageLoaderProvider); + final generation = ++_generation; + _loadingMore = false; + // Refresh the loaded range in bounded reads. Keep the old range visible + // until its replacement is ready, preserving the user's scroll position. + final target = math.max(request.limit, _rows.length); + final rows = []; + QueueLibraryDbCursor? cursor; + late LibraryBrowsePage page; + do { + page = await loadPage(_pageRequest(rows.length, cursor)); + if (!ref.mounted || generation != _generation) { + return LibraryBrowseState(rows, hasMore: false); } - final query = QueueLibraryDbQuery( - limit: request.limit, - includeSingleTrackAlbums: true, - albumArtist: request.artist, - searchQuery: request.search, - sortMode: request.sort, - includeLocal: includeLocal, - metadata: request.completeness, + rows.addAll(page.entries); + cursor = page.nextCursor; + } while (page.entries.length == request.limit && rows.length < target); + _rows = List.unmodifiable(rows); + _cursor = cursor; + _hasMore = page.entries.length == request.limit; + return LibraryBrowseState(_rows, hasMore: _hasMore); + } + + Future loadMore() async { + final current = state; + if (current.isLoading || current.hasError || _loadingMore || !_hasMore) { + return; + } + final generation = _generation; + _loadingMore = true; + state = AsyncData( + LibraryBrowseState(_rows, hasMore: _hasMore, isLoadingMore: true), + ); + try { + final page = await ref.read(libraryBrowsePageLoaderProvider)( + _pageRequest(_rows.length, _cursor), ); - final rows = request.artists - ? await LibraryDatabase.instance.getQueueArtistPage(query) - : await LibraryDatabase.instance.getQueueAlbumPage(query); - return rows - .map( - (row) => LibraryBrowseEntry.fromRow(row, isArtist: request.artists), - ) - .toList(growable: false); - }); + if (!ref.mounted || generation != _generation) return; + _rows = List.unmodifiable([..._rows, ...page.entries]); + _cursor = page.nextCursor; + _hasMore = page.entries.length == request.limit; + state = AsyncData(LibraryBrowseState(_rows, hasMore: _hasMore)); + } catch (error) { + if (!ref.mounted || generation != _generation) return; + state = AsyncData( + LibraryBrowseState(_rows, hasMore: _hasMore, loadMoreError: error), + ); + } finally { + if (ref.mounted && generation == _generation) _loadingMore = false; + } + } +} diff --git a/lib/screens/mornye_library_screen.dart b/lib/screens/mornye_library_screen.dart index 9ff39421..87283a61 100644 --- a/lib/screens/mornye_library_screen.dart +++ b/lib/screens/mornye_library_screen.dart @@ -44,7 +44,6 @@ class _MornyeLibraryScreenState extends ConsumerState { String? _completeness; String _query = ''; late String _sort; - int _limit = 40; List _rows = const []; bool _loading = true; @@ -55,7 +54,7 @@ class _MornyeLibraryScreenState extends ConsumerState { artist: widget.artist, search: _query, sort: _sort, - limit: _limit, + limit: 40, completeness: _completeness, ); @@ -66,8 +65,10 @@ class _MornyeLibraryScreenState extends ConsumerState { _scroll.addListener(() { if (_scroll.position.extentAfter < 500 && !_loading && - _rows.length >= _limit) { - setState(() => _limit += 40); + !(_overview && _query.isNotEmpty)) { + unawaited( + ref.read(libraryBrowseProvider(_request).notifier).loadMore(), + ); } }); } @@ -86,7 +87,6 @@ class _MornyeLibraryScreenState extends ConsumerState { if (!mounted || _query == value.trim()) return; setState(() { _query = value.trim(); - _limit = 40; _rows = const []; }); }); @@ -159,7 +159,6 @@ class _MornyeLibraryScreenState extends ConsumerState { if (!mounted || selected == null || selected == _sort) return; setState(() { _sort = selected; - _limit = 40; _rows = const []; }); _scroll.jumpTo(0); @@ -194,7 +193,6 @@ class _MornyeLibraryScreenState extends ConsumerState { } setState(() { _completeness = selected.isEmpty ? null : selected; - _limit = 40; _rows = const []; }); _scroll.jumpTo(0); @@ -237,10 +235,10 @@ class _MornyeLibraryScreenState extends ConsumerState { final colors = Theme.of(context).colorScheme; final searchingLibrary = _overview && _query.isNotEmpty; final result = searchingLibrary - ? const AsyncData>([]) + ? const AsyncData(LibraryBrowseState([], hasMore: false)) : ref.watch(libraryBrowseProvider(_request)); - _loading = result.isLoading; - if (result.hasValue) _rows = result.requireValue; + _loading = result.isLoading || (result.value?.isLoadingMore ?? false); + if (result.hasValue) _rows = result.requireValue.entries; final title = widget.artist ?? (_overview @@ -505,7 +503,7 @@ class _MornyeLibraryScreenState extends ConsumerState { }, ), ), - if (result.hasError) + if (result.hasError || result.value?.loadMoreError != null) SliverToBoxAdapter( child: Center( child: TextButton.icon( diff --git a/test/library_browse_paging_test.dart b/test/library_browse_paging_test.dart new file mode 100644 index 00000000..5a70c49f --- /dev/null +++ b/test/library_browse_paging_test.dart @@ -0,0 +1,295 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/providers/library_browse_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/library_database.dart'; + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(localLibraryEnabled: true); + + void includeLocal(bool value) => + state = state.copyWith(localLibraryEnabled: value); +} + +class _History extends DownloadHistoryNotifier { + @override + DownloadHistoryState build() => DownloadHistoryState(); + + void revise() => + state = state.copyWith(loadedIndexVersion: state.loadedIndexVersion + 1); +} + +class _Local extends LocalLibraryNotifier { + @override + LocalLibraryState build() => LocalLibraryState(); + + void progress() => state = state.copyWith(scanProgress: .5); + + void revise() => + state = state.copyWith(loadedIndexVersion: state.loadedIndexVersion + 1); +} + +LibraryBrowseEntry _entry(int index, {String prefix = ''}) => + LibraryBrowseEntry( + source: 'downloaded', + key: '$index', + name: '$prefix Album $index', + artist: 'Artist $index', + samplePath: '', + trackCount: 1, + ); + +const _request = ( + artists: false, + artist: null, + search: '', + sort: 'a-z', + limit: 40, + completeness: null, +); + +void main() { + late ProviderContainer container; + late List requests; + var count = 100; + var prefix = ''; + Completer? pending; + Object? failure; + + setUp(() { + requests = []; + count = 100; + prefix = ''; + pending = null; + failure = null; + container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + downloadHistoryProvider.overrideWith(_History.new), + localLibraryProvider.overrideWith(_Local.new), + libraryBrowsePageLoaderProvider.overrideWithValue((page) async { + requests.add(page); + if (failure != null) throw failure!; + if (pending != null) return pending!.future; + return LibraryBrowsePage( + [ + for ( + var i = page.offset; + i < count && i < page.offset + page.browse.limit; + i++ + ) + _entry(i, prefix: prefix), + ], + nextCursor: QueueLibraryDbCursor([page.offset + page.browse.limit]), + ); + }), + ], + ); + container.listen(libraryBrowseProvider(_request), (_, _) {}); + }); + tearDown(() => container.dispose()); + + test( + 'bounded album pages retain rows and forward cursors exactly once', + () async { + final provider = libraryBrowseProvider(_request); + final first = await container.read(provider.future); + expect(first.entries, hasLength(40)); + final notifier = container.read(provider.notifier); + pending = Completer(); + final loading = notifier.loadMore(); + expect( + container.read(provider).requireValue.entries, + same(first.entries), + ); + expect(container.read(provider).requireValue.isLoadingMore, true); + await notifier.loadMore(); + expect( + requests, + hasLength(2), + reason: 'concurrent loadMore is single flight', + ); + pending!.complete( + LibraryBrowsePage( + List.generate(40, (i) => _entry(i + 40)), + nextCursor: const QueueLibraryDbCursor([80]), + ), + ); + await loading; + pending = null; + await notifier.loadMore(); + await notifier.loadMore(); + expect(requests.map((page) => page.offset), [0, 40, 80]); + expect(requests.map((page) => page.browse.limit), [40, 40, 40]); + expect(requests[1].cursor, const QueueLibraryDbCursor([40])); + expect(requests[2].cursor, const QueueLibraryDbCursor([80])); + final result = container.read(provider).requireValue; + expect( + result.entries.map((entry) => entry.key), + List.generate(100, (i) => '$i'), + ); + expect(result.hasMore, false); + expect(result.isLoadingMore, false); + }, + ); + + test( + 'refresh and revisions replace the loaded range with bounded pages', + () async { + final provider = libraryBrowseProvider(_request); + await container.read(provider.future); + await container.read(provider.notifier).loadMore(); + prefix = 'Refreshed'; + container.invalidate(provider); + var result = await container.read(provider.future); + expect(result.entries, hasLength(80)); + expect(result.entries.first.name, startsWith('Refreshed')); + expect(requests.map((page) => page.offset), [0, 40, 0, 40]); + (container.read(localLibraryProvider.notifier) as _Local).progress(); + await container.pump(); + expect( + requests, + hasLength(4), + reason: 'scan progress must not refresh rows', + ); + (container.read(downloadHistoryProvider.notifier) as _History).revise(); + result = await container.read(provider.future); + expect(result.entries, hasLength(80)); + expect(requests.map((page) => page.offset), [0, 40, 0, 40, 0, 40]); + (container.read(settingsProvider.notifier) as _Settings).includeLocal( + false, + ); + await container.read(provider.future); + expect(requests.last.includeLocal, false); + final reads = requests.length; + (container.read(localLibraryProvider.notifier) as _Local).revise(); + await container.pump(); + expect( + requests, + hasLength(reads), + reason: 'disabled local index is not watched', + ); + }, + ); + + test('failed next page retains rows and retries the same cursor', () async { + final provider = libraryBrowseProvider(_request); + final first = await container.read(provider.future); + failure = StateError('fixture failure'); + await container.read(provider.notifier).loadMore(); + var result = container.read(provider).requireValue; + expect(result.entries, same(first.entries)); + expect(result.loadMoreError, same(failure)); + failure = null; + await container.read(provider.notifier).loadMore(); + result = container.read(provider).requireValue; + expect(result.entries, hasLength(80)); + expect(result.loadMoreError, isNull); + expect(requests.map((page) => page.offset), [0, 40, 40]); + expect(requests[1].cursor, requests[2].cursor); + }); + + test('index revision rejects a late next-page response', () async { + final provider = libraryBrowseProvider(_request); + await container.read(provider.future); + pending = Completer(); + final latePage = container.read(provider.notifier).loadMore(); + final completer = pending!; + pending = null; + prefix = 'Fresh'; + (container.read(downloadHistoryProvider.notifier) as _History).revise(); + final refreshed = await container.read(provider.future); + completer.complete(LibraryBrowsePage([_entry(999)])); + await latePage; + expect(container.read(provider).requireValue, same(refreshed)); + expect(refreshed.entries, hasLength(40)); + expect(refreshed.entries.first.name, startsWith('Fresh')); + }); + + test( + 'failed refresh cannot append from the previous revision cursor', + () async { + final provider = libraryBrowseProvider(_request); + await container.read(provider.future); + final notifier = container.read(provider.notifier); + await notifier.loadMore(); + expect(container.read(provider).requireValue.entries, hasLength(80)); + + failure = StateError('refresh failed'); + prefix = 'Fresh'; + (container.read(downloadHistoryProvider.notifier) as _History).revise(); + await expectLater( + container.read(provider.future), + throwsA(same(failure)), + ); + final failed = container.read(provider); + final readsAfterFailure = requests.length; + + failure = null; + await notifier.loadMore(); + expect(requests, hasLength(readsAfterFailure)); + expect(container.read(provider), same(failed)); + expect(container.read(provider).hasError, true); + + container.invalidate(provider); + final refreshed = await container.read(provider.future); + expect(refreshed.entries, hasLength(80)); + expect( + refreshed.entries.every((entry) => entry.name.startsWith('Fresh')), + true, + ); + expect(requests.skip(readsAfterFailure).map((page) => page.offset), [ + 0, + 40, + ]); + await notifier.loadMore(); + expect(container.read(provider).requireValue.entries, hasLength(100)); + expect( + container + .read(provider) + .requireValue + .entries + .every((entry) => entry.name.startsWith('Fresh')), + true, + ); + }, + ); + + test('artists use bounded offsets and new search resets paging', () async { + const artists = ( + artists: true, + artist: null, + search: 'Artist', + sort: 'a-z', + limit: 40, + completeness: null, + ); + final provider = libraryBrowseProvider(artists); + container.listen(provider, (_, _) {}); + await container.read(provider.future); + await container.read(provider.notifier).loadMore(); + expect(requests.last.offset, 40); + expect(requests.last.cursor, isNull); + expect(requests.last.browse.search, 'Artist'); + const search = ( + artists: false, + artist: 'Artist 1', + search: 'New query', + sort: 'latest', + limit: 40, + completeness: 'album-incomplete', + ); + final searched = libraryBrowseProvider(search); + container.listen(searched, (_, _) {}); + expect((await container.read(searched.future)).entries, hasLength(40)); + expect(requests.last.offset, 0); + expect(requests.last.cursor, isNull); + expect(requests.last.browse, search); + }); +} diff --git a/test/library_browse_sqlite_paging_test.dart b/test/library_browse_sqlite_paging_test.dart new file mode 100644 index 00000000..79128bac --- /dev/null +++ b/test/library_browse_sqlite_paging_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/providers/library_browse_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/album_completeness.dart'; +import 'package:spotiflac_android/services/library_queue_store.dart'; + +import 'support/library_paging_fixture.dart'; +import 'support/sqlite_process_database.dart'; + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(localLibraryEnabled: true); +} + +class _History extends DownloadHistoryNotifier { + @override + DownloadHistoryState build() => DownloadHistoryState(); +} + +class _Local extends LocalLibraryNotifier { + @override + LocalLibraryState build() => LocalLibraryState(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SqliteProcessDatabase db; + late LibraryQueueStore store; + setUp(() async { + db = await SqliteProcessDatabase.open(); + store = await createLibraryPagingFixture(db); + }); + tearDown(() => db.close()); + + test( + '1000 albums preserve source ties, dedup, filters and order on paging', + () async { + for (final scenario in [ + ( + artists: false, + sort: 'a-z', + search: '', + artist: null, + completeness: null, + ), + ( + artists: false, + sort: 'latest', + search: '', + artist: null, + completeness: null, + ), + ( + artists: true, + sort: 'a-z', + search: '', + artist: null, + completeness: null, + ), + ( + artists: false, + sort: 'a-z', + search: 'Album 00', + artist: null, + completeness: null, + ), + ( + artists: false, + sort: 'latest', + search: '', + artist: 'Artist 0001', + completeness: null, + ), + ( + artists: false, + sort: 'a-z', + search: '', + artist: null, + completeness: incompleteAlbumFilter, + ), + ( + artists: false, + sort: 'latest', + search: 'Album', + artist: null, + completeness: unknownAlbumCompletenessFilter, + ), + ]) { + final request = ( + artists: scenario.artists, + sort: scenario.sort, + search: scenario.search, + artist: scenario.artist, + completeness: scenario.completeness, + limit: 40, + ); + final reads = []; + var returned = 0; + final container = ProviderContainer( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + downloadHistoryProvider.overrideWith(_History.new), + localLibraryProvider.overrideWith(_Local.new), + libraryBrowsePageLoaderProvider.overrideWithValue((page) async { + reads.add(page); + final result = await readLibraryPagingFixture(store, page); + returned += result.entries.length; + return result; + }), + ], + ); + try { + final provider = libraryBrowseProvider(request); + container.listen(provider, (_, _) {}); + await container.read(provider.future); + final notifier = container.read(provider.notifier); + while (notifier.hasMore) { + await notifier.loadMore(); + expect(container.read(provider).requireValue.loadMoreError, isNull); + } + final actual = container.read(provider).requireValue.entries; + final expected = await readLibraryPagingFixture(store, ( + browse: ( + artists: request.artists, + sort: request.sort, + search: request.search, + artist: request.artist, + completeness: request.completeness, + limit: 2000, + ), + offset: 0, + cursor: null, + includeLocal: true, + )); + expect( + libraryPagingIdentities(actual), + libraryPagingIdentities(expected.entries), + reason: '$scenario', + ); + expect( + returned, + actual.length, + reason: 'every row is transferred once', + ); + expect(reads.every((page) => page.browse.limit == 40), true); + expect( + actual.every( + (entry) => entry.trackCount == (request.artists ? 4 : 2), + ), + true, + reason: 'mirrors and unavailable source must not increase counts', + ); + if (!request.artists && + request.search.isEmpty && + request.artist == null && + request.completeness == null) { + expect(actual, hasLength(1000)); + expect(reads.skip(1).every((page) => page.cursor != null), true); + } + } finally { + container.dispose(); + } + } + }, + ); +} diff --git a/test/mornye_library_browse_test.dart b/test/mornye_library_browse_test.dart index db55928f..e9c22874 100644 --- a/test/mornye_library_browse_test.dart +++ b/test/mornye_library_browse_test.dart @@ -4,13 +4,25 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_browse_provider.dart'; import 'package:spotiflac_android/providers/library_search_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/mornye_library_screen.dart'; import 'package:spotiflac_android/services/album_completeness.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(localLibraryEnabled: false); +} + +class _History extends DownloadHistoryNotifier { + @override + DownloadHistoryState build() => DownloadHistoryState(); +} + LibraryBrowseEntry _album(int index) => LibraryBrowseEntry( source: 'downloaded', key: 'album-$index', @@ -31,10 +43,13 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ - libraryBrowseProvider.overrideWith((ref, request) async { + settingsProvider.overrideWith(_Settings.new), + downloadHistoryProvider.overrideWith(_History.new), + libraryBrowsePageLoaderProvider.overrideWithValue((page) async { + final request = page.browse; requests.add(request); final filter = request.completeness; - return [ + return LibraryBrowsePage([ LibraryBrowseEntry.fromRow({ 'queue_source': 'downloaded', 'album_key': 'album', @@ -53,7 +68,7 @@ void main() { : null, }, }, isArtist: false), - ]; + ]); }), ], child: MaterialApp( @@ -131,9 +146,12 @@ void main() { ProviderScope( overrides: [ downloadQueueLookupProvider.overrideWithValue(queue), - libraryBrowseProvider.overrideWith((ref, request) async { + settingsProvider.overrideWith(_Settings.new), + downloadHistoryProvider.overrideWith(_History.new), + libraryBrowsePageLoaderProvider.overrideWithValue((page) async { + final request = page.browse; requests.add(request); - return [_album(0), _album(1)]; + return LibraryBrowsePage([_album(0), _album(1)]); }), librarySearchProvider.overrideWith((ref, request) async { searches.add(request); @@ -195,12 +213,19 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final requests = []; + final offsets = []; await tester.pumpWidget( ProviderScope( overrides: [ - libraryBrowseProvider.overrideWith((ref, request) async { + settingsProvider.overrideWith(_Settings.new), + downloadHistoryProvider.overrideWith(_History.new), + libraryBrowsePageLoaderProvider.overrideWithValue((page) async { + final request = page.browse; requests.add(request); - return List.generate(request.limit, _album); + offsets.add(page.offset); + return LibraryBrowsePage( + List.generate(request.limit, (i) => _album(page.offset + i)), + ); }), ], child: MaterialApp( @@ -222,7 +247,15 @@ void main() { ); scroll.controller!.jumpTo(scroll.controller!.position.maxScrollExtent); await tester.pumpAndSettle(); - expect(requests.last.limit, 80); + expect(requests.last.limit, 40); + expect(offsets, [0, 40]); + expect( + tester + .widget(find.byType(SliverGrid)) + .delegate + .estimatedChildCount, + 80, + ); scroll.controller!.jumpTo(0); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Search your library')); @@ -233,6 +266,7 @@ void main() { await tester.pumpAndSettle(); expect(requests.last.search, 'My album'); expect(requests.last.limit, 40); + expect(offsets.last, 0); expect(tester.takeException(), isNull); }); } diff --git a/test/support/library_paging_fixture.dart b/test/support/library_paging_fixture.dart new file mode 100644 index 00000000..9bfaa4a3 --- /dev/null +++ b/test/support/library_paging_fixture.dart @@ -0,0 +1,144 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/providers/library_browse_provider.dart'; +import 'package:spotiflac_android/services/library_queue_store.dart'; +import 'package:spotiflac_android/services/library_schema.dart'; + +/// Real schema17 local rows, two-source album ties, mirrored paths and an +/// unavailable source. The history read fixture uses the same named columns +/// and covering album index as the app; no network or production DB is touched. +Future createLibraryPagingFixture( + Database db, { + int albumsPerSource = 500, + String cover = '', +}) async { + await LibrarySchema.create(db, LibrarySchema.version); + await db.execute("ATTACH DATABASE ':memory:' AS history_db"); + await db.execute(''' + CREATE TABLE history_db.history AS + SELECT *, track_name_norm AS sort_track, artist_name_norm AS sort_artist, + album_name_norm AS sort_album, album_artist_norm AS sort_album_artist, + NULL AS cover_url, NULL AS downloaded_at, NULL AS service + FROM library WHERE 0 + '''); + await db.execute( + 'CREATE INDEX history_db.idx_history_album_key ON history(album_key)', + ); + await db.execute(''' + CREATE TABLE history_db.history_path_keys ( + item_id TEXT NOT NULL, path_key TEXT NOT NULL, + PRIMARY KEY (item_id, path_key) + ) + '''); + await db.execute(''' + CREATE INDEX history_db.idx_history_path_key + ON history_path_keys(path_key, item_id) + '''); + await db.insert('library_sources', { + 'id': 'offline', + 'path': '/fixture/offline', + 'display_name': 'Offline fixture', + 'available': 0, + }); + final batch = db.batch(); + void add(int index, int number, {required bool local, bool mirror = false}) { + final suffix = index.toString().padLeft(4, '0'); + final album = 'Album $suffix'; + final artist = 'Artist $suffix'; + final id = + '${mirror + ? 'mirror' + : local + ? 'local' + : 'download'}-$index-$number'; + final path = + '/fixture/${local && !mirror ? 'local' : 'download'}-$index-$number.flac'; + final fields = { + 'id': id, + 'track_name': 'Song $number', + 'artist_name': artist, + 'album_artist': artist, + 'album_name': album, + 'album_key': '${album.toLowerCase()}|${artist.toLowerCase()}', + 'file_path': path, + 'track_number': number, + 'disc_number': 1, + 'total_tracks': index % 3 == 2 + ? null + : index % 3 == 1 + ? 4 + : 2, + 'total_discs': 1, + 'sort_added': index ~/ 4, + 'release_date': '2026', + 'search_text': '$album $artist song $number'.toLowerCase(), + 'sort_genre': '', + 'sort_release': '2026', + if (local) ...{ + 'source_id': 'legacy', + 'scanned_at': '2026-10-07T00:00:00Z', + 'track_name_norm': 'song $number', + 'artist_name_norm': artist.toLowerCase(), + 'album_artist_norm': artist.toLowerCase(), + 'album_name_norm': album.toLowerCase(), + 'cover_path': cover, + } else ...{ + 'sort_track': 'song $number', + 'sort_artist': artist.toLowerCase(), + 'sort_album_artist': artist.toLowerCase(), + 'sort_album': album.toLowerCase(), + 'cover_url': cover, + 'downloaded_at': '2026-10-07T00:00:00Z', + 'service': 'example-provider', + }, + }; + batch.insert(local ? 'library' : 'history_db.history', fields); + batch.insert(local ? 'library_path_keys' : 'history_db.history_path_keys', { + 'item_id': id, + 'path_key': path, + }); + } + + for (var index = 0; index < albumsPerSource; index++) { + for (var number = 1; number <= 2; number++) { + add(index, number, local: false); + add(index, number, local: true); + } + if (index % 10 == 0) add(index, 1, local: true, mirror: true); + } + batch.insert('library', { + 'id': 'hidden', + 'source_id': 'offline', + 'track_name': 'Hidden track', + 'artist_name': 'Hidden artist', + 'album_name': 'Hidden album', + 'album_key': 'hidden|hidden', + 'file_path': '/fixture/hidden.flac', + 'scanned_at': '2026-10-07T00:00:00Z', + }); + await batch.commit(noResult: true); + return LibraryQueueStore(db, historyFts: false, localFts: false); +} + +Future readLibraryPagingFixture( + LibraryQueueStore store, + LibraryBrowsePageRequest request, +) async { + final query = libraryBrowseQuery(request); + if (request.browse.artists) { + return LibraryBrowsePage.fromRows( + await store.artistPage(query), + artists: true, + ); + } + final page = await store.albumPage(query); + return LibraryBrowsePage.fromRows( + page.rows, + artists: false, + nextCursor: page.nextCursor, + ); +} + +List libraryPagingIdentities(Iterable entries) => [ + for (final entry in entries) + '${entry.source}:${entry.key}:${entry.trackCount}:${entry.completeness?.badge}', +];