From 8ffd232b354498938c7e6b71c0ddc88e356be901 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:58:47 +0700 Subject: [PATCH] perf(collections): remove selected tracks in one transaction --- .../collection_remove_batch_test.dart | 346 ++++++++++++++++++ .../library_collections_provider.dart | 79 ++++ lib/screens/library_tracks_folder_screen.dart | 29 +- .../library_collections_database.dart | 85 ++++- test/collection_removal_transaction_test.dart | 123 +++++++ test/library_collections_mutation_test.dart | 132 +++++++ test/playlist_playback_test.dart | 112 ++++++ 7 files changed, 882 insertions(+), 24 deletions(-) create mode 100644 integration_test/collection_remove_batch_test.dart create mode 100644 test/collection_removal_transaction_test.dart diff --git a/integration_test/collection_remove_batch_test.dart b/integration_test/collection_remove_batch_test.dart new file mode 100644 index 00000000..33927a48 --- /dev/null +++ b/integration_test/collection_remove_batch_test.dart @@ -0,0 +1,346 @@ +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 '../test/support/library_collections_benchmark.dart'; +import '../test/support/playlist_batch_benchmark.dart'; + +class _Database implements LibraryCollectionsDatabase { + _Database(this.owner); + final Database owner; + + @override + Future deleteWishlistEntry(String key) async { + await owner.delete( + 'wishlist_tracks', + where: 'track_key=?', + whereArgs: [key], + ); + } + + @override + Future deleteLovedEntry(String key) async { + await owner.delete('loved_tracks', where: 'track_key=?', whereArgs: [key]); + } + + @override + Future deletePlaylistTrack({ + required String playlistId, + required String trackKey, + required String playlistUpdatedAt, + }) => owner.transaction((txn) async { + await txn.delete( + 'playlist_tracks', + where: 'playlist_id=? AND track_key=?', + whereArgs: [playlistId, trackKey], + ); + await txn.update( + 'playlists', + {'updated_at': playlistUpdatedAt}, + where: 'id=?', + whereArgs: [playlistId], + ); + }); + + @override + Future deleteWishlistTracks(List keys) => + LibraryCollectionsDatabase.deleteDatabaseWishlistTracks(owner, keys); + + @override + Future deleteLovedTracks(List keys) => + LibraryCollectionsDatabase.deleteDatabaseLovedTracks(owner, keys); + + @override + Future deletePlaylistTracks({ + required String playlistId, + required List trackKeys, + required String playlistUpdatedAt, + }) => LibraryCollectionsDatabase.deleteDatabasePlaylistTracks( + owner, + playlistId: playlistId, + trackKeys: trackKeys, + playlistUpdatedAt: playlistUpdatedAt, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Collections extends LibraryCollectionsNotifier { + _Collections(this._initial, {required super.database}); + final LibraryCollectionsState _initial; + @override + LibraryCollectionsState build() => _initial; +} + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final report = { + 'platform': Platform.operatingSystem, + 'note': + 'Five alternating pairs, real production notifier and private platform SQLite. Heartbeat is not FPS.', + }; + var completed = 0; + late Directory root; + late Database db; + final now = DateTime.utc(2026); + final stamp = now.toIso8601String(); + setUp(() async { + root = await Directory.systemTemp.createTemp('collection-remove-device-'); + db = await openDatabase( + '${root.path}/collections.db', + version: 2, + onConfigure: (db) async { + await db.execute('PRAGMA foreign_keys=ON'); + 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 { + for (final table in ['wishlist_tracks', 'loved_tracks']) { + await db.execute( + 'CREATE TABLE $table(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL)', + ); + await db.execute( + 'CREATE INDEX idx_${table}_added_at ON $table(added_at DESC)', + ); + } + await db.execute( + 'CREATE TABLE playlists(id TEXT PRIMARY KEY,name TEXT NOT NULL,cover_image_path TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL)', + ); + await db.execute( + '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) ON DELETE CASCADE)', + ); + await db.execute( + 'CREATE INDEX idx_playlist_tracks_playlist_id ON playlist_tracks(playlist_id)', + ); + }, + ); + }); + tearDown(() async { + await db.close(); + await root.delete(recursive: true); + }); + tearDownAll(() { + report['completed_cases'] = completed; + binding.reportData ??= {}; + binding.reportData!['collection_removal'] = report; + }); + + Future seed(int count) async { + final tracks = await playlistBatchFixture(count + 1); + final prepared = inlinePlaylistBatch(tracks, {}, now); + await db.transaction((txn) async { + final batch = txn.batch(); + for (final table in [ + 'playlist_tracks', + 'playlists', + 'wishlist_tracks', + 'loved_tracks', + ]) { + batch.delete(table); + } + for (final id in ['p', 'other']) { + batch.insert('playlists', { + 'id': id, + 'name': id, + 'cover_image_path': 'cover', + 'created_at': stamp, + 'updated_at': stamp, + }); + } + for (final row in prepared.rows) { + batch.insert('wishlist_tracks', row); + batch.insert('loved_tracks', row); + batch.insert('playlist_tracks', {'playlist_id': 'p', ...row}); + } + for (final row in prepared.rows.take(2)) { + batch.insert('playlist_tracks', {'playlist_id': 'other', ...row}); + } + await batch.commit(noResult: true); + }); + return LibraryCollectionsState( + isLoaded: true, + wishlist: prepared.entries.reversed.toList(), + loved: prepared.entries.reversed.toList(), + playlists: [ + for (final id in ['p', 'other']) + UserPlaylistCollection( + id: id, + name: id, + coverImagePath: 'cover', + createdAt: now, + updatedAt: now, + tracks: id == 'p' + ? prepared.entries + : prepared.entries.take(2).toList(), + ), + ], + ); + } + + ProviderContainer container(LibraryCollectionsState state) => + ProviderContainer( + overrides: [ + libraryCollectionsProvider.overrideWith( + () => _Collections(state, database: _Database(db)), + ), + ], + ); + + Future remove( + LibraryCollectionsNotifier notifier, + String kind, + List keys, + ) => switch (kind) { + 'wishlist' => notifier.removeWishlistTracks(keys), + 'loved' => notifier.removeLovedTracks(keys), + _ => notifier.removeTracksFromPlaylist('p', keys), + }; + + for (final kind in ['wishlist', 'loved', 'playlist']) { + testWidgets('remove 2000 $kind tracks in one selection', (tester) async { + final samples = >>{ + 'per_track': [], + 'batch': [], + }; + for (var pair = 0; pair < 5; pair++) { + for (final candidate in pair.isEven ? [false, true] : [true, false]) { + final initial = await seed(2000); + final owner = container(initial); + try { + final notifier = owner.read(libraryCollectionsProvider.notifier); + final keys = [ + for (var index = 0; index < 2000; index++) 'example:$index', + ]; + var publications = 0; + owner.listen(libraryCollectionsProvider, (_, _) => publications++); + final measured = await measureCollectionOperation(() async { + if (candidate) { + expect(await remove(notifier, kind, keys), 2000); + } else { + for (final key in keys) { + switch (kind) { + case 'wishlist': + await notifier.removeFromWishlist(key); + case 'loved': + await notifier.removeFromLoved(key); + case 'playlist': + await notifier.removeTrackFromPlaylist('p', key); + } + } + } + }); + expect(publications, candidate ? 1 : 2000); + final state = owner.read(libraryCollectionsProvider); + final remaining = switch (kind) { + 'wishlist' => state.wishlist, + 'loved' => state.loved, + _ => state.playlistById('p')!.tracks, + }; + expect(remaining.single.key, 'example:2000'); + expect(remaining.single.addedAt, now); + expect( + state.playlistById('other'), + same(initial.playlistById('other')), + ); + for (final table in ['wishlist_tracks', 'loved_tracks']) { + expect( + Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM $table'), + ), + table == '${kind}_tracks' ? 1 : 2001, + ); + } + expect( + Sqflite.firstIntValue( + await db.rawQuery( + "SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id='other'", + ), + ), + 2, + ); + expect( + Sqflite.firstIntValue( + await db.rawQuery( + "SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id='p'", + ), + ), + kind == 'playlist' ? 1 : 2001, + ); + final rows = await db.query( + '${kind}_tracks', + where: kind == 'playlist' ? 'playlist_id=?' : null, + whereArgs: kind == 'playlist' ? ['p'] : null, + ); + expect( + rows.single['track_json'], + jsonEncode(remaining.single.track.toJson()), + ); + expect(rows.single['added_at'], stamp); + expect( + (await db.query( + 'playlists', + where: 'id=?', + whereArgs: ['p'], + )).single['updated_at'], + state.playlistById('p')!.updatedAt.toIso8601String(), + ); + samples[candidate ? 'batch' : 'per_track']!.add({ + ...measured.timing, + 'publications': publications, + }); + } finally { + owner.dispose(); + } + } + } + report[kind] = samples; + completed++; + }); + + testWidgets('$kind rollback retains state and retry succeeds', ( + tester, + ) async { + final initial = await seed(300); + final owner = container(initial); + try { + final notifier = owner.read(libraryCollectionsProvider.notifier); + var publications = 0; + owner.listen(libraryCollectionsProvider, (_, _) => publications++); + await db.execute( + "CREATE TRIGGER reject_delete BEFORE DELETE ON ${kind}_tracks WHEN old.track_key='example:150' BEGIN SELECT RAISE(ABORT,'denied'); END", + ); + final keys = [for (var i = 0; i < 300; i++) 'example:$i']; + await expectLater( + remove(notifier, kind, keys), + throwsA(isA()), + ); + expect(owner.read(libraryCollectionsProvider), same(initial)); + expect(publications, 0); + expect( + Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT COUNT(*) FROM ${kind}_tracks${kind == 'playlist' ? " WHERE playlist_id='p'" : ''}', + ), + ), + 301, + ); + await db.execute('DROP TRIGGER reject_delete'); + expect(await remove(notifier, kind, keys), 300); + expect(publications, 1); + expect(await db.rawQuery('PRAGMA foreign_key_check'), isEmpty); + completed++; + } finally { + owner.dispose(); + } + }); + } +} diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index 0e68d30d..27631f43 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -363,6 +363,48 @@ class LibraryCollectionsNotifier extends Notifier { dbDelete: _db.deleteLovedEntry, ); + Future _removeTrackEntries( + Iterable trackKeys, { + required bool Function(String key) contains, + required List Function() select, + required LibraryCollectionsState Function(List) + withList, + required Future Function(List) dbDelete, + }) { + final selected = trackKeys.toSet(); + return _mutate(() async { + final keys = selected.where(contains).toSet(); + if (keys.isEmpty) return 0; + await dbDelete(keys.toList(growable: false)); + if (ref.mounted) { + state = withList( + select() + .where((entry) => !keys.contains(entry.key)) + .toList(growable: false), + ); + } + return keys.length; + }); + } + + Future removeWishlistTracks(Iterable trackKeys) => + _removeTrackEntries( + trackKeys, + contains: (key) => state.containsWishlistKey(key), + select: () => state.wishlist, + withList: (list) => state.copyWith(wishlist: list), + dbDelete: _db.deleteWishlistTracks, + ); + + Future removeLovedTracks(Iterable trackKeys) => + _removeTrackEntries( + trackKeys, + contains: (key) => state.containsLovedKey(key), + select: () => state.loved, + withList: (list) => state.copyWith(loved: list), + dbDelete: _db.deleteLovedTracks, + ); + Future createPlaylist(String name) => _mutate(() async { final now = DateTime.now(); final id = 'pl_${now.microsecondsSinceEpoch}'; @@ -544,6 +586,43 @@ class LibraryCollectionsNotifier extends Notifier { _invalidatePlaylistPickerSummaries(); }); + Future removeTracksFromPlaylist( + String playlistId, + Iterable trackKeys, + ) { + final selected = trackKeys.toSet(); + return _mutate(() async { + var playlist = state.playlistById(playlistId); + if (playlist == null || !selected.any(playlist.containsTrackKey)) { + return 0; + } + await ensurePlaylistLoaded(playlistId); + if (!ref.mounted) return 0; + playlist = state.playlistById(playlistId); + if (playlist == null) return 0; + final keys = selected.where(playlist.containsTrackKey).toSet(); + if (keys.isEmpty) return 0; + + final now = DateTime.now(); + await _db.deletePlaylistTracks( + playlistId: playlistId, + trackKeys: keys.toList(growable: false), + playlistUpdatedAt: now.toIso8601String(), + ); + _replacePlaylistById( + playlistId, + (current) => current.copyWith( + tracks: current.tracks + .where((entry) => !keys.contains(entry.key)) + .toList(growable: false), + updatedAt: now, + ), + ); + _invalidatePlaylistPickerSummaries(); + return keys.length; + }); + } + Future _playlistCoversDir() async { final appDir = await getApplicationSupportDirectory(); final dir = Directory(p.join(appDir.path, 'playlist_covers')); diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 9e9cb792..7abb082c 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -165,20 +165,21 @@ class _LibraryTracksFolderScreenState final count = keysToRemove.length; final notifier = ref.read(libraryCollectionsProvider.notifier); - for (final key in keysToRemove) { - switch (widget.mode) { - case LibraryTracksFolderMode.wishlist: - await notifier.removeFromWishlist(key); - break; - case LibraryTracksFolderMode.loved: - await notifier.removeFromLoved(key); - break; - case LibraryTracksFolderMode.playlist: - if (widget.playlistId != null) { - await notifier.removeTrackFromPlaylist(widget.playlistId!, key); - } - break; - } + switch (widget.mode) { + case LibraryTracksFolderMode.wishlist: + await notifier.removeWishlistTracks(keysToRemove); + break; + case LibraryTracksFolderMode.loved: + await notifier.removeLovedTracks(keysToRemove); + break; + case LibraryTracksFolderMode.playlist: + if (widget.playlistId != null) { + await notifier.removeTracksFromPlaylist( + widget.playlistId!, + keysToRemove, + ); + } + break; } exitSelectionMode(); diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index fea32c00..6332c5d0 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -616,6 +616,22 @@ class LibraryCollectionsDatabase { Future deleteWishlistEntry(String trackKey) => _deleteEntry(_tableWishlist, 'track', trackKey); + Future deleteWishlistTracks(List keys) async { + if (keys.isEmpty) return; + await deleteDatabaseWishlistTracks(await database, keys); + } + + static Future deleteDatabaseWishlistTracks( + Database db, + List keys, + ) async { + if (keys.isEmpty) return; + await sqlite.transactionWithBusyRetry( + db, + (txn) => _deleteTrackKeys(txn, _tableWishlist, keys), + ); + } + Future upsertLovedEntry({ required String trackKey, required String trackJson, @@ -677,16 +693,28 @@ class LibraryCollectionsDatabase { 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, - ); - } - }); + await sqlite.transactionWithBusyRetry( + db, + (txn) => _deleteTrackKeys(txn, _tableLoved, keys), + ); + } + + static Future _deleteTrackKeys( + DatabaseExecutor db, + String table, + List keys, { + String? playlistId, + }) async { + for (var start = 0; start < keys.length; start += 128) { + final end = start + 128 < keys.length ? start + 128 : keys.length; + final chunk = keys.sublist(start, end); + await db.delete( + table, + where: + '${playlistId == null ? '' : 'playlist_id = ? AND '}track_key IN (${List.filled(chunk.length, '?').join(',')})', + whereArgs: [?playlistId, ...chunk], + ); + } } Future upsertFavoriteArtistEntry({ @@ -859,6 +887,43 @@ class LibraryCollectionsDatabase { }); } + Future deletePlaylistTracks({ + required String playlistId, + required List trackKeys, + required String playlistUpdatedAt, + }) async { + if (trackKeys.isEmpty) return; + await deleteDatabasePlaylistTracks( + await database, + playlistId: playlistId, + trackKeys: trackKeys, + playlistUpdatedAt: playlistUpdatedAt, + ); + } + + static Future deleteDatabasePlaylistTracks( + Database db, { + required String playlistId, + required List trackKeys, + required String playlistUpdatedAt, + }) async { + if (trackKeys.isEmpty) return; + await sqlite.transactionWithBusyRetry(db, (txn) async { + await _deleteTrackKeys( + txn, + _tablePlaylistTracks, + trackKeys, + playlistId: playlistId, + ); + await txn.update( + _tablePlaylists, + {'updated_at': playlistUpdatedAt}, + where: 'id = ?', + whereArgs: [playlistId], + ); + }); + } + /// Wipes every collection table and rewrites them from a restored backup. /// /// [collectionsJson] must use the same shape as diff --git a/test/collection_removal_transaction_test.dart b/test/collection_removal_transaction_test.dart new file mode 100644 index 00000000..8ce0181f --- /dev/null +++ b/test/collection_removal_transaction_test.dart @@ -0,0 +1,123 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/library_collections_database.dart'; + +import 'support/sqlite_process_database.dart'; + +void main() { + for (final kind in ['wishlist', 'loved', 'playlist']) { + for (final failure in [ + 'none', + 'row', + if (kind == 'playlist') 'timestamp', + ]) { + test( + '$kind removal preserves other rows and rolls back all chunks (failure: $failure)', + () async { + final root = await Directory.systemTemp.createTemp( + 'collection-removal-', + ); + final db = await SqliteProcessDatabase.open( + path: '${root.path}/collections.db', + ); + addTearDown(() async { + await db.close(); + await root.delete(recursive: true); + }); + final table = '${kind}_tracks'; + await db.execute( + 'CREATE TABLE playlists(id TEXT PRIMARY KEY,updated_at TEXT)', + ); + await db.execute( + "INSERT INTO playlists VALUES('p','old'),('other','old')", + ); + await db.execute( + kind == 'playlist' + ? 'CREATE TABLE $table(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))' + : 'CREATE TABLE $table(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL)', + ); + final batch = db.batch(); + for (final playlist in ['p', if (kind == 'playlist') 'other']) { + for (var index = 0; index < 301; index++) { + batch.insert(table, { + if (kind == 'playlist') 'playlist_id': playlist, + 'track_key': '$index', + 'track_json': '{"title":"音楽 $index"}', + 'added_at': 'original-$index', + }); + } + } + await batch.commit(noResult: true); + final before = await db.rawQuery( + 'SELECT rowid,* FROM $table ORDER BY rowid', + ); + if (failure == 'row') { + await db.execute( + "CREATE TRIGGER reject_delete BEFORE DELETE ON $table WHEN old.track_key='150' BEGIN SELECT RAISE(ABORT,'denied'); END", + ); + } else if (failure == 'timestamp') { + await db.execute( + "CREATE TRIGGER reject_delete BEFORE UPDATE ON playlists BEGIN SELECT RAISE(ABORT,'denied'); END", + ); + } + Future remove(List keys) => switch (kind) { + 'wishlist' => + LibraryCollectionsDatabase.deleteDatabaseWishlistTracks(db, keys), + 'loved' => LibraryCollectionsDatabase.deleteDatabaseLovedTracks( + db, + keys, + ), + _ => LibraryCollectionsDatabase.deleteDatabasePlaylistTracks( + db, + playlistId: 'p', + trackKeys: keys, + playlistUpdatedAt: 'new', + ), + }; + final operation = remove([for (var i = 0; i < 300; i++) '$i']); + if (failure != 'none') { + await expectLater(operation, throwsStateError); + expect( + await db.rawQuery('SELECT rowid,* FROM $table ORDER BY rowid'), + before, + ); + expect( + await db.rawQuery('SELECT updated_at FROM playlists WHERE id=?', [ + 'p', + ]), + [ + {'updated_at': 'old'}, + ], + ); + await db.execute('DROP TRIGGER reject_delete'); + await remove([for (var i = 0; i < 300; i++) '$i']); + } else { + await operation; + } + expect( + await db.rawQuery('SELECT rowid,* FROM $table ORDER BY rowid'), + [ + for (final row in before) + if (row['track_key'] == '300' || + (kind == 'playlist' && row['playlist_id'] == 'other')) + row, + ], + ); + final playlists = await db.rawQuery( + 'SELECT * FROM playlists ORDER BY rowid', + ); + expect(playlists, [ + {'id': 'p', 'updated_at': kind == 'playlist' ? 'new' : 'old'}, + {'id': 'other', 'updated_at': 'old'}, + ]); + await remove(const []); + expect( + await db.rawQuery('SELECT * FROM playlists ORDER BY rowid'), + playlists, + ); + }, + ); + } + } +} diff --git a/test/library_collections_mutation_test.dart b/test/library_collections_mutation_test.dart index 0def6161..fc3f4bf3 100644 --- a/test/library_collections_mutation_test.dart +++ b/test/library_collections_mutation_test.dart @@ -114,6 +114,14 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase { Future deleteWishlistEntry(String trackKey) => _write('delete:wishlist:$trackKey', () => wishlist.remove(trackKey)); + @override + Future deleteWishlistTracks(List keys) => + _write('bulk-wishlist:remove', () { + for (final key in keys) { + wishlist.remove(key); + } + }); + @override Future upsertLovedEntry({ required String trackKey, @@ -193,6 +201,19 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase { } }); + @override + Future deletePlaylistTracks({ + required String playlistId, + required List trackKeys, + required String playlistUpdatedAt, + }) => _write('bulk-playlist:remove', () { + final keys = trackKeys.toSet(); + playlistTracks[playlistId]!.removeWhere( + (row) => keys.contains(row['track_key']), + ); + playlists[playlistId]!['updated_at'] = playlistUpdatedAt; + }); + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -219,6 +240,117 @@ ProviderContainer _container(_CollectionsDatabase db) { } void main() { + for (final kind in ['wishlist', 'loved', 'playlist']) { + for (final failFirst in [false, true]) { + test( + 'bulk $kind removal is atomic and serializes repeated clicks (fail: $failFirst)', + () async { + final db = _CollectionsDatabase()..failNextWrite = failFirst; + final tracks = [ + for (final id in ['a', 'b', 'keep']) _track(id), + ]; + final rows = [ + for (final track in tracks) + { + 'track_key': trackCollectionKey(track), + 'track_json': jsonEncode(track.toJson()), + 'added_at': '2026-01-01T00:00:00Z', + }, + ]; + for (final row in rows) { + db.wishlist[row['track_key'] as String] = {...row}; + db.loved[row['track_key'] as String] = {...row}; + } + for (final id in ['p', 'other']) { + db.playlists[id] = { + 'id': id, + 'name': id, + 'cover_image_path': 'cover', + 'created_at': '2026-01-01T00:00:00Z', + 'updated_at': '2026-01-01T00:00:00Z', + }; + db.playlistTracks[id] = [ + for (final row in rows) {...row}, + ]; + } + db.releasePlaylistRead.complete(); + final container = _container(db); + final notifier = container.read(libraryCollectionsProvider.notifier); + Future remove(Iterable keys) => switch (kind) { + 'wishlist' => notifier.removeWishlistTracks(keys), + 'loved' => notifier.removeLovedTracks(keys), + _ => notifier.removeTracksFromPlaylist('p', keys), + }; + final selected = ['example:a', 'example:b', 'example:a', 'missing']; + final first = remove(selected); + selected + ..clear() + ..add('example:keep'); + final failure = failFirst + ? expectLater(first, throwsStateError) + : null; + await db.writeStarted.future; + final before = container.read(libraryCollectionsProvider); + var publications = 0; + container.listen( + libraryCollectionsProvider, + (_, _) => publications++, + ); + final second = remove(['example:a', 'example:b']); + expect(db.events, ['bulk-$kind:remove']); + expect(container.read(libraryCollectionsProvider), same(before)); + db.releaseWrite.complete(); + if (failure != null) { + await failure; + } else { + expect(await first, 2); + } + expect(await second, failFirst ? 2 : 0); + expect(publications, 1); + final after = container.read(libraryCollectionsProvider); + final remaining = switch (kind) { + 'wishlist' => after.wishlist, + 'loved' => after.loved, + _ => after.playlistById('p')!.tracks, + }; + expect(remaining.map((entry) => entry.track.id), ['keep']); + expect(remaining.single.addedAt, DateTime.utc(2026)); + expect( + after.playlistById('other'), + same(before.playlistById('other')), + ); + expect(db.playlistTracks['other'], rows); + expect(db.wishlist.length, kind == 'wishlist' ? 1 : 3); + expect(db.loved.length, kind == 'loved' ? 1 : 3); + expect(db.playlistTracks['p']!.length, kind == 'playlist' ? 1 : 3); + expect(db.events.length, failFirst ? 2 : 1); + expect(db.playlistReads, kind == 'playlist' ? 1 : 0); + if (kind == 'playlist') { + final playlist = after.playlistById('p')!; + expect(playlist.coverImagePath, 'cover'); + expect(playlist.createdAt, DateTime.utc(2026)); + expect(playlist.trackKeys, {'example:keep'}); + expect(playlist.updatedAt.isAfter(DateTime.utc(2026)), isTrue); + expect( + db.playlists['p']!['updated_at'], + playlist.updatedAt.toIso8601String(), + ); + } + expect(await remove(const []), 0); + expect(await remove(['missing']), 0); + expect( + await notifier.removeTracksFromPlaylist('missing', [ + 'example:keep', + ]), + 0, + ); + expect(container.read(libraryCollectionsProvider), same(after)); + expect(publications, 1); + }, + ); + } + } + for (final failFirst in [false, true]) { test( 'Love All is atomic and serializes repeated clicks (failure: $failFirst)', diff --git a/test/playlist_playback_test.dart b/test/playlist_playback_test.dart index 1c2bf01c..0e495b61 100644 --- a/test/playlist_playback_test.dart +++ b/test/playlist_playback_test.dart @@ -97,6 +97,49 @@ class _Collections extends LibraryCollectionsNotifier { Future ensurePlaylistLoaded(String playlistId) async {} } +class _RemovalCollections extends _Collections { + _RemovalCollections(this._record); + final void Function(String, List) _record; + + @override + LibraryCollectionsState build() { + final initial = super.build(); + final tracks = initial.playlists.single.tracks; + return initial.copyWith(wishlist: tracks, loved: tracks); + } + + Future _remove(String kind, Iterable keys) async { + final selected = keys.toSet(); + _record(kind, selected.toList()); + List keep(List entries) => + entries.where((entry) => !selected.contains(entry.key)).toList(); + state = switch (kind) { + 'wishlist' => state.copyWith(wishlist: keep(state.wishlist)), + 'loved' => state.copyWith(loved: keep(state.loved)), + _ => state.copyWith( + playlists: [ + state.playlists.single.copyWith( + tracks: keep(state.playlists.single.tracks), + ), + ], + ), + }; + return selected.length; + } + + @override + Future removeWishlistTracks(Iterable keys) => + _remove('wishlist', keys); + + @override + Future removeLovedTracks(Iterable keys) => + _remove('loved', keys); + + @override + Future removeTracksFromPlaylist(String id, Iterable keys) => + _remove('playlist', keys); +} + class _Library extends LocalLibraryNotifier { @override LocalLibraryState build() => LocalLibraryState(); @@ -111,6 +154,75 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() => SharedPreferences.setMockInitialValues({})); + for (final mode in LibraryTracksFolderMode.values) { + for (final mornye in [false, true]) { + testWidgets( + 'selection dispatches one ${mode.name} removal (Mornye: $mornye)', + (tester) async { + final calls = <(String, List)>[]; + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + musicPlayerControllerProvider.overrideWithValue(_Player()), + playbackProvider.overrideWith(_Playback.new), + libraryCollectionsProvider.overrideWith( + () => _RemovalCollections( + (kind, keys) => calls.add((kind, keys)), + ), + ), + localLibraryProvider.overrideWith(_Library.new), + downloadHistoryProvider.overrideWith(_History.new), + downloadHistoryVisibleBatchExistsProvider.overrideWith( + (ref, request) => const {}, + ), + localLibraryCoverProvider.overrideWith( + (ref, request) async => null, + ), + localLibraryFirstCoverProvider.overrideWith( + (ref, request) async => null, + ), + ], + child: MaterialApp( + theme: mornye + ? MornyeTheme.build(Brightness.dark) + : AppTheme.light(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: LibraryTracksFolderScreen( + mode: mode, + playlistId: mode == LibraryTracksFolderMode.playlist + ? 'playlist' + : null, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('First')); + await tester.longPress(find.text('First')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Unavailable')); + await tester.tap(find.text('Unavailable')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.remove_circle_outline)); + await tester.pumpAndSettle(); + expect(calls, hasLength(1)); + expect(calls.single.$1, mode.name); + expect(calls.single.$2, ['First', 'Unavailable']); + expect(find.text('First'), findsNothing); + expect(find.text('Unavailable'), findsNothing); + expect(find.byIcon(Icons.remove_circle_outline), findsNothing); + final l10n = AppLocalizations.of( + tester.element(find.byType(LibraryTracksFolderScreen)), + ); + expect(find.text(l10n.selectionSelected(2)), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } + } + for (final mornye in [false, true]) { testWidgets('playlist row queues its neighbors (Mornye: $mornye)', ( tester,