mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
perf(library): load bounded browse pages without rereading prefixes
This commit is contained in:
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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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}',
|
||||
];
|
||||
Reference in new issue
Block a user