perf(library): load bounded browse pages without rereading prefixes

This commit is contained in:
zarzet committed 2026-10-07 04:19:14 +07:00
1 parent e7d0d88035
commit 100999e5f5
8 files changed
+1184 -51

No files matched your search

@@ -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<LibraryBrowsePage> Function(LibraryBrowsePageRequest) readPage;
late int _limit;
Future<LibraryBrowseState> _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<LibraryBrowseState> build() {
_limit = request.limit;
return _read();
}
@override
Future<void> 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<void> _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<Uint8List> _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 = <Map<String, Object?>>[];
final pixelChecks = <Map<String, Object?>>[];
Future<LibraryBrowsePage> 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<void> 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<void> painted() async {
binding.scheduleFrame();
await binding.endOfFrame;
}
Future<void> 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<CustomScrollView>(find.byType(CustomScrollView))
.controller!;
Future<void> 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<void> 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',
},
);
});
}
@@ -84,6 +84,7 @@ void main() {
final index = ValueNotifier(0);
addTearDown(index.dispose);
final requests = <LibraryBrowseRequest>[];
final pageOffsets = <int>[];
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',
+164 -28
View File
@@ -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<List<LibraryBrowseEntry>, 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<LibraryBrowseEntry> entries;
final QueueLibraryDbCursor? nextCursor;
const LibraryBrowsePage(this.entries, {this.nextCursor});
factory LibraryBrowsePage.fromRows(
List<Map<String, dynamic>> rows, {
required bool artists,
QueueLibraryDbCursor? nextCursor,
}) => LibraryBrowsePage(
rows
.map((row) => LibraryBrowseEntry.fromRow(row, isArtist: artists))
.toList(growable: false),
nextCursor: nextCursor,
);
}
class LibraryBrowseState {
final List<LibraryBrowseEntry> 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<Future<LibraryBrowsePage> 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, LibraryBrowseState, LibraryBrowseRequest>(
LibraryBrowseNotifier.new,
);
class LibraryBrowseNotifier extends AsyncNotifier<LibraryBrowseState> {
LibraryBrowseNotifier(this.request);
final LibraryBrowseRequest request;
List<LibraryBrowseEntry> _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<LibraryBrowseState> 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 = <LibraryBrowseEntry>[];
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<void> 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;
}
}
}
+9 -11
View File
@@ -44,7 +44,6 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
String? _completeness;
String _query = '';
late String _sort;
int _limit = 40;
List<LibraryBrowseEntry> _rows = const [];
bool _loading = true;
@@ -55,7 +54,7 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
artist: widget.artist,
search: _query,
sort: _sort,
limit: _limit,
limit: 40,
completeness: _completeness,
);
@@ -66,8 +65,10 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
_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<MornyeLibraryScreen> {
if (!mounted || _query == value.trim()) return;
setState(() {
_query = value.trim();
_limit = 40;
_rows = const [];
});
});
@@ -159,7 +159,6 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
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<MornyeLibraryScreen> {
}
setState(() {
_completeness = selected.isEmpty ? null : selected;
_limit = 40;
_rows = const [];
});
_scroll.jumpTo(0);
@@ -237,10 +235,10 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
final colors = Theme.of(context).colorScheme;
final searchingLibrary = _overview && _query.isNotEmpty;
final result = searchingLibrary
? const AsyncData<List<LibraryBrowseEntry>>([])
? 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<MornyeLibraryScreen> {
},
),
),
if (result.hasError)
if (result.hasError || result.value?.loadMoreError != null)
SliverToBoxAdapter(
child: Center(
child: TextButton.icon(
+295
View File
@@ -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<LibraryBrowsePageRequest> requests;
var count = 100;
var prefix = '';
Completer<LibraryBrowsePage>? 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);
});
}
+170
View File
@@ -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 = <LibraryBrowsePageRequest>[];
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();
}
}
},
);
}
+42 -8
View File
@@ -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 = <LibraryBrowseRequest>[];
final offsets = <int>[];
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<SliverGrid>(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);
});
}
+144
View File
@@ -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<LibraryQueueStore> 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 = <String, Object?>{
'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<LibraryBrowsePage> 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<String> libraryPagingIdentities(Iterable<LibraryBrowseEntry> entries) => [
for (final entry in entries)
'${entry.source}:${entry.key}:${entry.trackCount}:${entry.completeness?.badge}',
];