From 20e0573d0963281491607a5ec3bf0f57cc9760bd Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:01:50 +0700 Subject: [PATCH] perf(collections): batch Love All updates and persistence --- .../collection_batch_write_test.dart | 12 + integration_test/loved_batch_write_test.dart | 227 ++++++++++++++++++ .../playlist_batch_write_test.dart | 17 +- .../library_collections_provider.dart | 57 ++++- ...riter.dart => collection_track_batch.dart} | 64 ++++- .../library_collections_database.dart | 60 ++++- lib/widgets/track_detail_actions.dart | 45 +--- test/collection_track_rebuild_test.dart | 54 +++++ test/library_collections_mutation_test.dart | 103 ++++++++ test/playlist_batch_benchmark_test.dart | 4 +- test/playlist_batch_publication_test.dart | 83 ++++++- test/playlist_batch_writer_test.dart | 10 +- 12 files changed, 679 insertions(+), 57 deletions(-) create mode 100644 integration_test/collection_batch_write_test.dart create mode 100644 integration_test/loved_batch_write_test.dart rename lib/services/{playlist_batch_writer.dart => collection_track_batch.dart} (76%) diff --git a/integration_test/collection_batch_write_test.dart b/integration_test/collection_batch_write_test.dart new file mode 100644 index 00000000..8ddc84b0 --- /dev/null +++ b/integration_test/collection_batch_write_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'loved_batch_write_test.dart' as loved; +import 'playlist_batch_write_test.dart' as playlists; + +void main() { + // Register the driver's completion hook outside either fixture group. + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('Playlist batches', playlists.main); + group('Loved batches', loved.main); +} diff --git a/integration_test/loved_batch_write_test.dart b/integration_test/loved_batch_write_test.dart new file mode 100644 index 00000000..971b959d --- /dev/null +++ b/integration_test/loved_batch_write_test.dart @@ -0,0 +1,227 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/services/library_collections_database.dart'; +import 'package:spotiflac_android/services/collection_track_batch.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +import '../test/support/library_collections_benchmark.dart'; +import '../test/support/playlist_batch_benchmark.dart'; + +// Redirect only persistence to a private fixture. The notifier and the existing +// per-track Love All operations run unchanged on the real platform SQLite. +class _Database implements LibraryCollectionsDatabase { + _Database(this.owner); + final Database owner; + + @override + Future upsertLovedEntry({ + required String trackKey, + required String trackJson, + required String addedAt, + }) async { + await owner.insert('loved_tracks', { + 'track_key': trackKey, + 'track_json': trackJson, + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + @override + Future deleteLovedEntry(String trackKey) async { + await owner.delete( + 'loved_tracks', + where: 'track_key=?', + whereArgs: [trackKey], + ); + } + + @override + Future upsertLovedTracksBatch(List> rows) => + LibraryCollectionsDatabase.writeDatabaseLovedTracks(owner, rows); + + @override + Future deleteLovedTracks(List keys) => + LibraryCollectionsDatabase.deleteDatabaseLovedTracks(owner, keys); + + @override + Future upsertLovedTracksFile({ + required String addedAt, + required String rowsPath, + required int expectedCount, + }) => publishLovedBatch( + database: owner, + addedAt: addedAt, + rowsPath: rowsPath, + expectedCount: expectedCount, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Collections extends LibraryCollectionsNotifier { + _Collections({required super.database}); + + @override + LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true); +} + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final report = { + 'platform': Platform.operatingSystem, + 'note': + 'Five alternating pairs, real notifier and private SQLite. Heartbeat is not FPS.', + }; + var completed = 0; + tearDownAll(() { + report['completed_cases'] = completed; + binding.reportData ??= {}; + binding.reportData!['loved_batch'] = report; + }); + + for (final count in [2000, 3000]) { + testWidgets('Love All paired add and remove on actual SQLite ($count)', ( + tester, + ) async { + final root = await Directory.systemTemp.createTemp('loved-batch-device-'); + final extensions = await Directory('${root.path}/extensions').create(); + final data = await Directory('${root.path}/data').create(); + final output = await Directory('${root.path}/output').create(); + await PlatformBridge.initExtensionSystem( + extensions.path, + data.path, + masterKey: base64Encode(List.filled(32, 1)), + lyricsProviders: const [], + lyricsFetchOptions: const {}, + allowedDirectories: [output.path], + ); + expect( + (await PlatformBridge.getBackendImplementations())['extensions'], + 'rust', + ); + final db = await openDatabase( + '${root.path}/collections.db', + version: 2, + onConfigure: (db) async { + await db.execute('PRAGMA auto_vacuum=INCREMENTAL'); + await db.execute('PRAGMA recursive_triggers=ON'); + await db.rawQuery('PRAGMA journal_mode=WAL'); + await db.execute('PRAGMA synchronous=NORMAL'); + await db.rawQuery('PRAGMA busy_timeout=5000'); + }, + onCreate: (db, _) async { + await db.execute( + 'CREATE TABLE loved_tracks(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL)', + ); + await db.execute( + 'CREATE INDEX idx_loved_tracks_added_at ON loved_tracks(added_at DESC)', + ); + }, + ); + final tracks = await playlistBatchFixture(count); + final baseline = >[]; + final optimized = >[]; + try { + for (var sample = 0; sample < 5; sample++) { + for (final candidate + in sample.isEven ? [false, true] : [true, false]) { + final container = ProviderContainer( + overrides: [ + libraryCollectionsProvider.overrideWith( + () => _Collections(database: _Database(db)), + ), + ], + ); + try { + final notifier = container.read( + libraryCollectionsProvider.notifier, + ); + var publications = 0; + container.listen( + libraryCollectionsProvider, + (_, _) => publications++, + ); + final add = await measureCollectionOperation(() async { + if (candidate) { + expect(await notifier.toggleLovedTracks(tracks), ( + removed: false, + count: count, + )); + return; + } + for (final track in tracks) { + await notifier.toggleLoved(track); + } + }); + final state = container.read(libraryCollectionsProvider); + expect( + state.loved.map((entry) => entry.track.id), + tracks.reversed.map((track) => track.id), + ); + expect(publications, candidate ? 1 : tracks.length); + expect( + Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM loved_tracks'), + ), + tracks.length, + ); + final rows = await db.rawQuery( + 'SELECT track_key,track_json,added_at FROM loved_tracks ORDER BY added_at DESC,rowid DESC', + ); + expect( + rows.map((row) => row['track_key']), + state.loved.map((entry) => entry.key), + ); + for (var index = 0; index < rows.length; index++) { + expect( + rows[index]['track_json'], + jsonEncode(state.loved[index].track.toJson()), + ); + expect( + rows[index]['added_at'], + state.loved[index].addedAt.toIso8601String(), + ); + } + publications = 0; + final remove = await measureCollectionOperation(() async { + if (candidate) { + expect(await notifier.toggleLovedTracks(tracks), ( + removed: true, + count: count, + )); + return; + } + for (final track in tracks) { + await notifier.removeFromLoved(trackCollectionKey(track)); + } + }); + expect(container.read(libraryCollectionsProvider).loved, isEmpty); + expect(await db.rawQuery('SELECT * FROM loved_tracks'), isEmpty); + expect(publications, candidate ? 1 : tracks.length); + (candidate ? optimized : baseline).add({ + 'add': add.timing, + 'remove': remove.timing, + 'publications_per_operation': publications, + }); + } finally { + container.dispose(); + } + } + } + report['$count'] = {'baseline': baseline, 'optimized': optimized}; + completed++; + } finally { + await db.close(); + await PlatformBridge.cleanupExtensions(); + await root.delete(recursive: true); + } + }); + } +} diff --git a/integration_test/playlist_batch_write_test.dart b/integration_test/playlist_batch_write_test.dart index 25f45043..d74afca4 100644 --- a/integration_test/playlist_batch_write_test.dart +++ b/integration_test/playlist_batch_write_test.dart @@ -9,7 +9,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:spotiflac_android/models/library_collections.dart'; import 'package:spotiflac_android/services/library_collections_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; -import 'package:spotiflac_android/services/playlist_batch_writer.dart'; +import 'package:spotiflac_android/services/collection_track_batch.dart'; import '../test/support/library_collections_benchmark.dart'; import '../test/support/playlist_batch_benchmark.dart'; @@ -86,10 +86,14 @@ void main() { tearDownAll(() { report['completed_cases'] = completed; - binding.reportData = {'playlist_batch_write': report}; + binding.reportData ??= {}; + binding.reportData!['playlist_batch_write'] = report; }); - Future publish(PreparedPlaylistBatch batch, {String id = 'p'}) async { + Future publish( + PreparedCollectionTrackBatch batch, { + String id = 'p', + }) async { if (batch.rowsPath == null) { await LibraryCollectionsDatabase.writeDatabasePlaylistTracks( db, @@ -152,7 +156,7 @@ void main() { final result = await measureCollectionOperation(() async { late List additions; if (candidate) { - final batch = await preparePlaylistBatch( + final batch = await prepareCollectionTrackBatch( tracks: input, existingKeys: playlist.trackKeys, addedAt: now, @@ -213,7 +217,8 @@ void main() { } report['$count'] = {'baseline': baseline, 'optimized': optimized}; completed++; - binding.reportData = {'playlist_batch_write': report}; + binding.reportData ??= {}; + binding.reportData!['playlist_batch_write'] = report; }); } @@ -221,7 +226,7 @@ void main() { testWidgets('rollback and recovery through actual SQLite ($count)', ( tester, ) async { - final batch = await preparePlaylistBatch( + final batch = await prepareCollectionTrackBatch( tracks: await playlistBatchFixture(count), existingKeys: const {}, addedAt: now, diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index 7a88bcd0..0e68d30d 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -9,7 +9,7 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/library_collections_database.dart'; import 'package:spotiflac_android/services/library_collections_hydration.dart'; -import 'package:spotiflac_android/services/playlist_batch_writer.dart'; +import 'package:spotiflac_android/services/collection_track_batch.dart'; import 'package:spotiflac_android/utils/logger.dart'; export 'package:spotiflac_android/models/library_collections.dart'; @@ -218,6 +218,59 @@ class LibraryCollectionsNotifier extends Notifier { dbUpsert: _db.upsertLovedEntry, ); + /// One selection is one mutation: mixed selections add only missing tracks, + /// fully loved selections remove them, and observers see one committed state. + Future<({bool removed, int count})> toggleLovedTracks( + Iterable tracks, + ) => _mutate(() async { + final selected = tracks.toList(growable: false); + if (selected.isEmpty) return (removed: false, count: 0); + if (selected.every(state.isLoved)) { + final keys = selected.map(trackCollectionKey).toSet(); + await _db.deleteLovedTracks(keys.toList(growable: false)); + if (ref.mounted) { + state = state.copyWith( + loved: state.loved + .where((entry) => !keys.contains(entry.key)) + .toList(growable: false), + ); + } + return (removed: true, count: keys.length); + } + + final now = DateTime.now(); + final prepared = await prepareCollectionTrackBatch( + tracks: selected, + existingKeys: state.loved.map((entry) => entry.key).toSet(), + addedAt: now, + ); + try { + if (!ref.mounted || prepared.entries.isEmpty) { + return (removed: false, count: 0); + } + final rowsPath = prepared.rowsPath; + if (rowsPath != null) { + await _db.upsertLovedTracksFile( + addedAt: now.toIso8601String(), + rowsPath: rowsPath, + expectedCount: prepared.entries.length, + ); + } else { + await _db.upsertLovedTracksBatch(prepared.rows); + } + if (ref.mounted) { + // The latest added track appears first, matching the original + // per-track inserts and the database's DESC rowid tie-breaker. + state = state.copyWith( + loved: [...prepared.entries.reversed, ...state.loved], + ); + } + return (removed: false, count: prepared.entries.length); + } finally { + await prepared.dispose(); + } + }); + Future toggleFavoriteArtist({ required String artistId, required String? providerId, @@ -422,7 +475,7 @@ class LibraryCollectionsNotifier extends Notifier { } final now = DateTime.now(); - final prepared = await preparePlaylistBatch( + final prepared = await prepareCollectionTrackBatch( tracks: tracks, existingKeys: playlist.trackKeys, addedAt: now, diff --git a/lib/services/playlist_batch_writer.dart b/lib/services/collection_track_batch.dart similarity index 76% rename from lib/services/playlist_batch_writer.dart rename to lib/services/collection_track_batch.dart index ef426d5b..96dc0ce5 100644 --- a/lib/services/playlist_batch_writer.dart +++ b/lib/services/collection_track_batch.dart @@ -15,10 +15,10 @@ import 'package:spotiflac_android/utils/logger.dart'; const _workerThreshold = 1024; const _nativeThreshold = 2048; var _attachmentSequence = 0; -final _log = AppLogger('PlaylistBatch'); +final _log = AppLogger('CollectionTrackBatch'); -class PreparedPlaylistBatch { - const PreparedPlaylistBatch({ +class PreparedCollectionTrackBatch { + const PreparedCollectionTrackBatch({ required this.entries, required this.duplicates, required this.rows, @@ -40,7 +40,7 @@ class PreparedPlaylistBatch { /// Deduplication and encoding share one worker. Large native batches return a /// file path instead of transferring thousands of JSON maps back through UI. -Future preparePlaylistBatch({ +Future prepareCollectionTrackBatch({ required Iterable tracks, required Set existingKeys, required DateTime addedAt, @@ -69,7 +69,7 @@ Future preparePlaylistBatch({ } } -PreparedPlaylistBatch _prepare( +PreparedCollectionTrackBatch _prepare( List tracks, Set existingKeys, DateTime addedAt, @@ -118,7 +118,7 @@ PreparedPlaylistBatch _prepare( }); } } - return PreparedPlaylistBatch( + return PreparedCollectionTrackBatch( entries: entries, duplicates: duplicates, rows: rows, @@ -137,7 +137,45 @@ Future publishPlaylistBatch({ required int expectedCount, Future> Function(Map)? nativeJobRunner, Directory? temporaryDirectory, +}) => _publishTrackBatch( + database: database, + playlistId: playlistId, + updatedAt: updatedAt, + rowsPath: rowsPath, + expectedCount: expectedCount, + nativeJobRunner: nativeJobRunner, + temporaryDirectory: temporaryDirectory, +); + +Future publishLovedBatch({ + required Database database, + required String addedAt, + required String rowsPath, + required int expectedCount, + Future> Function(Map)? nativeJobRunner, + Directory? temporaryDirectory, +}) => _publishTrackBatch( + database: database, + playlistId: null, + updatedAt: addedAt, + rowsPath: rowsPath, + expectedCount: expectedCount, + nativeJobRunner: nativeJobRunner, + temporaryDirectory: temporaryDirectory, +); + +Future _publishTrackBatch({ + required Database database, + required String? playlistId, + required String updatedAt, + required String rowsPath, + required int expectedCount, + required Future> Function(Map)? + nativeJobRunner, + required Directory? temporaryDirectory, }) async { + // The native staging format contains only ordered keys and track JSON. + // Both destinations share it; only this owner chooses the live table. final directory = await (temporaryDirectory ?? await getTemporaryDirectory()) .createTemp('playlist-stage-'); final stagedPath = '${directory.path}/playlist_additions.db'; @@ -153,7 +191,7 @@ Future publishPlaylistBatch({ result['published'] != true || result['count'] != expectedCount || result['path'] != stagedPath) { - throw const FormatException('Incomplete playlist staging'); + throw const FormatException('Incomplete collection track staging'); } final alias = 'playlist_additions_${_attachmentSequence++}'; await database.execute('ATTACH DATABASE ? AS $alias', [stagedPath]); @@ -161,6 +199,16 @@ Future publishPlaylistBatch({ var committed = false; try { await sqlite.transactionWithBusyRetry(database, (txn) async { + if (playlistId == null) { + await txn.execute( + 'INSERT OR REPLACE INTO main.loved_tracks ' + '(track_key,track_json,added_at) ' + 'SELECT track_key,track_json,? FROM $alias.playlist_additions ' + 'ORDER BY position', + [updatedAt], + ); + return; + } await txn.execute( 'INSERT OR REPLACE INTO main.playlist_tracks ' '(playlist_id,track_key,track_json,added_at) ' @@ -182,7 +230,7 @@ Future publishPlaylistBatch({ detached = true; } on NativeSqliteSnapshotDetachException { if (!committed) rethrow; - _log.w('Playlist saved; retaining staging after failed DETACH'); + _log.w('Collection saved; retaining staging after failed DETACH'); } } } finally { diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index cacb80ed..fea32c00 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -8,7 +8,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:spotiflac_android/models/library_collections.dart'; import 'package:spotiflac_android/services/collection_restore_codec.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; -import 'package:spotiflac_android/services/playlist_batch_writer.dart'; +import 'package:spotiflac_android/services/collection_track_batch.dart'; import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; import 'package:spotiflac_android/services/sqlite_native_snapshot.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -631,6 +631,64 @@ class LibraryCollectionsDatabase { Future deleteLovedEntry(String trackKey) => _deleteEntry(_tableLoved, 'track', trackKey); + Future upsertLovedTracksBatch(List> tracks) async { + if (tracks.isEmpty) return; + await writeDatabaseLovedTracks(await database, tracks); + } + + static Future writeDatabaseLovedTracks( + Database db, + List> tracks, + ) async { + if (tracks.isEmpty) return; + await sqlite.transactionWithBusyRetry(db, (txn) async { + for (var start = 0; start < tracks.length; start += 128) { + final batch = txn.batch(); + for (final row in tracks.skip(start).take(128)) { + batch.insert( + _tableLoved, + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } + }); + } + + Future upsertLovedTracksFile({ + required String addedAt, + required String rowsPath, + required int expectedCount, + }) async => publishLovedBatch( + database: await database, + addedAt: addedAt, + rowsPath: rowsPath, + expectedCount: expectedCount, + ); + + Future deleteLovedTracks(List keys) async { + if (keys.isEmpty) return; + await deleteDatabaseLovedTracks(await database, keys); + } + + static Future deleteDatabaseLovedTracks( + Database db, + List keys, + ) async { + if (keys.isEmpty) return; + await sqlite.transactionWithBusyRetry(db, (txn) async { + for (var start = 0; start < keys.length; start += 128) { + final chunk = keys.skip(start).take(128).toList(growable: false); + await txn.delete( + _tableLoved, + where: 'track_key IN (${List.filled(chunk.length, '?').join(',')})', + whereArgs: chunk, + ); + } + }); + } + Future upsertFavoriteArtistEntry({ required String artistKey, required String artistJson, diff --git a/lib/widgets/track_detail_actions.dart b/lib/widgets/track_detail_actions.dart index e539016c..90540777 100644 --- a/lib/widgets/track_detail_actions.dart +++ b/lib/widgets/track_detail_actions.dart @@ -339,38 +339,19 @@ Future loveAllTracks( WidgetRef ref, List tracks, ) async { - final notifier = ref.read(libraryCollectionsProvider.notifier); - final state = ref.read(libraryCollectionsProvider); - final allLoved = tracks.every((t) => state.isLoved(t)); - - if (allLoved) { - for (final track in tracks) { - final key = trackCollectionKey(track); - await notifier.removeFromLoved(key); - } - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.snackbarRemovedTracksFromLoved(tracks.length), - ), + if (tracks.isEmpty) return; + final result = await ref + .read(libraryCollectionsProvider.notifier) + .toggleLovedTracks(tracks); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.removed + ? context.l10n.snackbarRemovedTracksFromLoved(result.count) + : context.l10n.snackbarAddedTracksToLoved(result.count), ), - ); - } - } else { - int addedCount = 0; - for (final track in tracks) { - if (!state.isLoved(track)) { - await notifier.toggleLoved(track); - addedCount++; - } - } - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)), - ), - ); - } + ), + ); } } diff --git a/test/collection_track_rebuild_test.dart b/test/collection_track_rebuild_test.dart index 8a840ee5..3965ffe0 100644 --- a/test/collection_track_rebuild_test.dart +++ b/test/collection_track_rebuild_test.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/theme/app_theme.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/track_list_tile.dart'; +import 'package:spotiflac_android/widgets/track_detail_actions.dart'; const _track = Track( id: 'example-track', @@ -31,6 +32,7 @@ class _Settings extends SettingsNotifier { } class _Collections extends LibraryCollectionsNotifier { + var _batchCalls = 0; @override LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true); @@ -42,6 +44,16 @@ class _Collections extends LibraryCollectionsNotifier { void publishWishlist() => state = state.copyWith(wishlist: [_entry]); void publishLoved() => state = state.copyWith(loved: [_entry]); + + @override + Future<({bool removed, int count})> toggleLovedTracks( + Iterable tracks, + ) async { + _batchCalls++; + final removed = state.loved.isNotEmpty; + state = state.copyWith(loved: removed ? [] : [_entry]); + return (removed: removed, count: 1); + } } class _Recent extends RecentAccessNotifier { @@ -140,6 +152,48 @@ void main() { ); for (final mornye in [false, true]) { + testWidgets( + 'Love All action keeps its add/remove feedback in Mornye=$mornye', + (tester) async { + final collections = _Collections(); + final container = ProviderContainer( + overrides: [ + libraryCollectionsProvider.overrideWith(() => collections), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + _app( + container, + Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => loveAllTracks(context, ref, [_track]), + child: const Text('Apply'), + ), + ), + mornye: mornye, + ), + ); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + final context = tester.element(find.text('Apply')); + expect( + find.text(context.l10n.snackbarAddedTracksToLoved(1)), + findsOneWidget, + ); + ScaffoldMessenger.of(context).removeCurrentSnackBar(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + expect( + find.text(context.l10n.snackbarRemovedTracksFromLoved(1)), + findsOneWidget, + ); + expect(collections._batchCalls, 2); + expect(tester.takeException(), isNull); + }, + ); + for (final album in [false, true]) { testWidgets('Love All isolates the $album page in Mornye=$mornye', ( tester, diff --git a/test/library_collections_mutation_test.dart b/test/library_collections_mutation_test.dart index 73d0219d..0def6161 100644 --- a/test/library_collections_mutation_test.dart +++ b/test/library_collections_mutation_test.dart @@ -177,6 +177,22 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase { playlists[playlistId]!['updated_at'] = playlistUpdatedAt; }); + @override + Future upsertLovedTracksBatch(List> tracks) => + _write('bulk-loved:add', () { + for (final row in tracks) { + loved[row['track_key']!] = Map.from(row); + } + }); + + @override + Future deleteLovedTracks(List keys) => + _write('bulk-loved:remove', () { + for (final key in keys) { + loved.remove(key); + } + }); + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -203,6 +219,93 @@ ProviderContainer _container(_CollectionsDatabase db) { } void main() { + for (final failFirst in [false, true]) { + test( + 'Love All is atomic and serializes repeated clicks (failure: $failFirst)', + () async { + final db = _CollectionsDatabase()..failNextWrite = failFirst; + final container = _container(db); + final notifier = container.read(libraryCollectionsProvider.notifier); + var publications = 0; + container.listen(libraryCollectionsProvider, (_, _) => publications++); + final selected = [_track('1'), _track('1'), _track('2')]; + final first = notifier.toggleLovedTracks(selected); + final failure = failFirst ? expectLater(first, throwsStateError) : null; + await db.writeStarted.future; + publications = 0; + expect(container.read(libraryCollectionsProvider).loved, isEmpty); + final second = notifier.toggleLovedTracks(selected); + expect(db.events, ['bulk-loved:add']); + db.releaseWrite.complete(); + if (failure != null) { + await failure; + } else { + expect(await first, (removed: false, count: 2)); + } + expect(await second, (removed: !failFirst, count: 2)); + expect(publications, failFirst ? 1 : 2); + expect( + container + .read(libraryCollectionsProvider) + .loved + .map((entry) => entry.track.id), + failFirst ? ['2', '1'] : isEmpty, + ); + expect(db.loved.length, failFirst ? 2 : 0); + expect(db.events, [ + 'bulk-loved:add', + failFirst ? 'bulk-loved:add' : 'bulk-loved:remove', + ]); + }, + ); + } + + test( + 'mixed Love All preserves existing metadata and unrelated loved tracks', + () async { + final db = _CollectionsDatabase(); + for (final id in ['existing', 'unrelated']) { + db.loved[trackCollectionKey(_track(id))] = { + 'track_key': trackCollectionKey(_track(id)), + 'track_json': jsonEncode(_track(id).toJson()), + 'added_at': '2026-01-01T00:00:00Z', + }; + } + final container = _container(db); + final notifier = container.read(libraryCollectionsProvider.notifier); + final selected = [_track('existing'), _track('new'), _track('new')]; + final add = notifier.toggleLovedTracks(selected); + await db.writeStarted.future; + db.releaseWrite.complete(); + expect(await add, (removed: false, count: 1)); + expect( + container + .read(libraryCollectionsProvider) + .loved + .map((entry) => entry.track.id), + ['new', 'existing', 'unrelated'], + ); + expect( + db.loved[trackCollectionKey(_track('existing'))]!['added_at'], + '2026-01-01T00:00:00Z', + ); + expect(await notifier.toggleLovedTracks(selected), ( + removed: true, + count: 2, + )); + expect( + container.read(libraryCollectionsProvider).loved.single.track.id, + 'unrelated', + ); + final before = db.events.length; + expect(await notifier.toggleLovedTracks(const []), ( + removed: false, + count: 0, + )); + expect(db.events.length, before); + }, + ); + for (final failFirst in [false, true]) { test( 'bulk additions publish after persistence and serialize duplicates (failure: $failFirst)', diff --git a/test/playlist_batch_benchmark_test.dart b/test/playlist_batch_benchmark_test.dart index 467eb194..4f2f3a21 100644 --- a/test/playlist_batch_benchmark_test.dart +++ b/test/playlist_batch_benchmark_test.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:spotiflac_android/services/playlist_batch_writer.dart'; +import 'package:spotiflac_android/services/collection_track_batch.dart'; import 'support/library_collections_benchmark.dart'; import 'support/playlist_batch_benchmark.dart'; @@ -20,7 +20,7 @@ void main() { for (final worker in index.isEven ? [false, true] : [true, false]) { if (worker) { final result = await measureCollectionOperation( - () => preparePlaylistBatch( + () => prepareCollectionTrackBatch( tracks: tracks, existingKeys: const {}, addedAt: DateTime.utc(2026, 10, 8), diff --git a/test/playlist_batch_publication_test.dart b/test/playlist_batch_publication_test.dart index bc4b81bd..aaa573d5 100644 --- a/test/playlist_batch_publication_test.dart +++ b/test/playlist_batch_publication_test.dart @@ -3,7 +3,8 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite/sqflite.dart'; -import 'package:spotiflac_android/services/playlist_batch_writer.dart'; +import 'package:spotiflac_android/services/collection_track_batch.dart'; +import 'package:spotiflac_android/services/library_collections_database.dart'; import 'support/sqlite_process_database.dart'; @@ -45,6 +46,9 @@ void main() { 'CREATE TABLE playlist_tracks(playlist_id TEXT NOT NULL, track_key TEXT NOT NULL, track_json TEXT NOT NULL, added_at TEXT NOT NULL, PRIMARY KEY(playlist_id,track_key), FOREIGN KEY(playlist_id) REFERENCES playlists(id))', ); await live.execute('CREATE TABLE published(track_key TEXT)'); + await live.execute( + 'CREATE TABLE loved_tracks(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL)', + ); await live.execute( 'CREATE TRIGGER audit AFTER INSERT ON playlist_tracks BEGIN INSERT INTO published VALUES(new.track_key); END', ); @@ -134,6 +138,83 @@ void main() { }, ); + for (final fail in [false, true]) { + test('shared staging publishes Loved atomically (failure: $fail)', () async { + if (fail) { + await live.execute( + "CREATE TRIGGER fail_loved BEFORE INSERT ON loved_tracks WHEN new.track_key='new-b' BEGIN SELECT RAISE(ABORT,'denied'); END", + ); + } + final operation = publishLovedBatch( + database: live, + addedAt: 'new', + rowsPath: input, + expectedCount: 2, + nativeJobRunner: stage, + temporaryDirectory: root, + ); + if (fail) { + await expectLater(operation, throwsA(isA())); + expect(await live.rawQuery('SELECT * FROM loved_tracks'), isEmpty); + } else { + await operation; + expect( + (await live.rawQuery( + 'SELECT track_key FROM loved_tracks ORDER BY added_at DESC,rowid DESC', + )).map((row) => row['track_key']), + ['new-b', 'new-a'], + ); + } + expect(await File(retainedStage!).exists(), isFalse); + expect((await live.rawQuery('PRAGMA database_list')).length, 1); + expect( + (await live.rawQuery( + 'SELECT track_key FROM playlist_tracks', + )).single['track_key'], + 'existing', + ); + expect( + (await live.rawQuery( + "SELECT updated_at FROM playlists WHERE id='p'", + )).single['updated_at'], + 'old', + ); + }); + } + + test('bounded Loved inserts and deletes roll back earlier chunks', () async { + final rows = List.generate( + 400, + (index) => { + 'track_key': 'key-$index', + 'track_json': '{}', + 'added_at': 'new', + }, + ); + await live.execute( + "CREATE TRIGGER fail_loved BEFORE INSERT ON loved_tracks WHEN new.track_key='key-300' BEGIN SELECT RAISE(ABORT,'denied'); END", + ); + await expectLater( + LibraryCollectionsDatabase.writeDatabaseLovedTracks(live, rows), + throwsA(isA()), + ); + expect(await live.rawQuery('SELECT * FROM loved_tracks'), isEmpty); + await live.execute('DROP TRIGGER fail_loved'); + await LibraryCollectionsDatabase.writeDatabaseLovedTracks(live, rows); + await live.execute( + "CREATE TRIGGER fail_delete BEFORE DELETE ON loved_tracks WHEN old.track_key='key-300' BEGIN SELECT RAISE(ABORT,'denied'); END", + ); + final keys = rows.map((row) => row['track_key']!).toList(); + await expectLater( + LibraryCollectionsDatabase.deleteDatabaseLovedTracks(live, keys), + throwsA(isA()), + ); + expect((await live.rawQuery('SELECT * FROM loved_tracks')).length, 400); + await live.execute('DROP TRIGGER fail_delete'); + await LibraryCollectionsDatabase.deleteDatabaseLovedTracks(live, keys); + expect(await live.rawQuery('SELECT * FROM loved_tracks'), isEmpty); + }); + test( 'timestamp failure rolls back all inserted tracks and trigger effects', () async { diff --git a/test/playlist_batch_writer_test.dart b/test/playlist_batch_writer_test.dart index 285c3d86..8b017d45 100644 --- a/test/playlist_batch_writer_test.dart +++ b/test/playlist_batch_writer_test.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/services/playlist_batch_writer.dart'; +import 'package:spotiflac_android/services/collection_track_batch.dart'; import 'support/playlist_batch_benchmark.dart'; @@ -37,7 +37,7 @@ void main() { final tracks = [...fixture, fixture[1], fixture[3]]; final keys = {'example:0', 'example:1'}; final baseline = inlinePlaylistBatch(tracks, keys, now); - final prepared = await preparePlaylistBatch( + final prepared = await prepareCollectionTrackBatch( tracks: tracks, existingKeys: keys, addedAt: now, @@ -85,7 +85,7 @@ void main() { albumName: 'Album', duration: 1, ); - final prepared = await preparePlaylistBatch( + final prepared = await prepareCollectionTrackBatch( tracks: [ track('first', 'one', ' xx001 '), track('second', 'two', 'XX001'), @@ -101,7 +101,7 @@ void main() { test('mostly duplicate batches retain the small persistence route', () async { final fixture = await playlistBatchFixture(12); - final prepared = await preparePlaylistBatch( + final prepared = await prepareCollectionTrackBatch( tracks: [for (var index = 0; index < 256; index++) ...fixture], existingKeys: const {}, addedAt: now, @@ -118,7 +118,7 @@ void main() { test('failed encoding removes its partial row file', () async { final fixture = await playlistBatchFixture(2500); await expectLater( - preparePlaylistBatch( + prepareCollectionTrackBatch( tracks: [...fixture, _InvalidTrack()], existingKeys: const {}, addedAt: now,