perf(collections): remove selected tracks in one transaction

This commit is contained in:
zarzet committed 2026-10-08 04:58:47 +07:00
1 parent 105f1b0946
commit 8ffd232b35
7 files changed
+882 -24

No files matched your search

@@ -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<void> deleteWishlistEntry(String key) async {
await owner.delete(
'wishlist_tracks',
where: 'track_key=?',
whereArgs: [key],
);
}
@override
Future<void> deleteLovedEntry(String key) async {
await owner.delete('loved_tracks', where: 'track_key=?', whereArgs: [key]);
}
@override
Future<void> 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<void> deleteWishlistTracks(List<String> keys) =>
LibraryCollectionsDatabase.deleteDatabaseWishlistTracks(owner, keys);
@override
Future<void> deleteLovedTracks(List<String> keys) =>
LibraryCollectionsDatabase.deleteDatabaseLovedTracks(owner, keys);
@override
Future<void> deletePlaylistTracks({
required String playlistId,
required List<String> 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 = <String, dynamic>{
'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<LibraryCollectionsState> 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<int> remove(
LibraryCollectionsNotifier notifier,
String kind,
List<String> 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 = <String, List<Map<String, int>>>{
'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<DatabaseException>()),
);
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();
}
});
}
}
@@ -363,6 +363,48 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
dbDelete: _db.deleteLovedEntry,
);
Future<int> _removeTrackEntries(
Iterable<String> trackKeys, {
required bool Function(String key) contains,
required List<CollectionTrackEntry> Function() select,
required LibraryCollectionsState Function(List<CollectionTrackEntry>)
withList,
required Future<void> Function(List<String>) 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<int> removeWishlistTracks(Iterable<String> trackKeys) =>
_removeTrackEntries(
trackKeys,
contains: (key) => state.containsWishlistKey(key),
select: () => state.wishlist,
withList: (list) => state.copyWith(wishlist: list),
dbDelete: _db.deleteWishlistTracks,
);
Future<int> removeLovedTracks(Iterable<String> trackKeys) =>
_removeTrackEntries(
trackKeys,
contains: (key) => state.containsLovedKey(key),
select: () => state.loved,
withList: (list) => state.copyWith(loved: list),
dbDelete: _db.deleteLovedTracks,
);
Future<String> createPlaylist(String name) => _mutate(() async {
final now = DateTime.now();
final id = 'pl_${now.microsecondsSinceEpoch}';
@@ -544,6 +586,43 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
_invalidatePlaylistPickerSummaries();
});
Future<int> removeTracksFromPlaylist(
String playlistId,
Iterable<String> 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<Directory> _playlistCoversDir() async {
final appDir = await getApplicationSupportDirectory();
final dir = Directory(p.join(appDir.path, 'playlist_covers'));
+15 -14
View File
@@ -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();
+75 -10
View File
@@ -616,6 +616,22 @@ class LibraryCollectionsDatabase {
Future<void> deleteWishlistEntry(String trackKey) =>
_deleteEntry(_tableWishlist, 'track', trackKey);
Future<void> deleteWishlistTracks(List<String> keys) async {
if (keys.isEmpty) return;
await deleteDatabaseWishlistTracks(await database, keys);
}
static Future<void> deleteDatabaseWishlistTracks(
Database db,
List<String> keys,
) async {
if (keys.isEmpty) return;
await sqlite.transactionWithBusyRetry(
db,
(txn) => _deleteTrackKeys(txn, _tableWishlist, keys),
);
}
Future<void> upsertLovedEntry({
required String trackKey,
required String trackJson,
@@ -677,16 +693,28 @@ class LibraryCollectionsDatabase {
List<String> 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<void> _deleteTrackKeys(
DatabaseExecutor db,
String table,
List<String> 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<void> upsertFavoriteArtistEntry({
@@ -859,6 +887,43 @@ class LibraryCollectionsDatabase {
});
}
Future<void> deletePlaylistTracks({
required String playlistId,
required List<String> trackKeys,
required String playlistUpdatedAt,
}) async {
if (trackKeys.isEmpty) return;
await deleteDatabasePlaylistTracks(
await database,
playlistId: playlistId,
trackKeys: trackKeys,
playlistUpdatedAt: playlistUpdatedAt,
);
}
static Future<void> deleteDatabasePlaylistTracks(
Database db, {
required String playlistId,
required List<String> 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
@@ -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<void> remove(List<String> 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,
);
},
);
}
}
}
+132
View File
@@ -114,6 +114,14 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase {
Future<void> deleteWishlistEntry(String trackKey) =>
_write('delete:wishlist:$trackKey', () => wishlist.remove(trackKey));
@override
Future<void> deleteWishlistTracks(List<String> keys) =>
_write('bulk-wishlist:remove', () {
for (final key in keys) {
wishlist.remove(key);
}
});
@override
Future<void> upsertLovedEntry({
required String trackKey,
@@ -193,6 +201,19 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase {
}
});
@override
Future<void> deletePlaylistTracks({
required String playlistId,
required List<String> 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)
<String, dynamic>{
'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<int> remove(Iterable<String> 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)',
+112
View File
@@ -97,6 +97,49 @@ class _Collections extends LibraryCollectionsNotifier {
Future<void> ensurePlaylistLoaded(String playlistId) async {}
}
class _RemovalCollections extends _Collections {
_RemovalCollections(this._record);
final void Function(String, List<String>) _record;
@override
LibraryCollectionsState build() {
final initial = super.build();
final tracks = initial.playlists.single.tracks;
return initial.copyWith(wishlist: tracks, loved: tracks);
}
Future<int> _remove(String kind, Iterable<String> keys) async {
final selected = keys.toSet();
_record(kind, selected.toList());
List<CollectionTrackEntry> keep(List<CollectionTrackEntry> 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<int> removeWishlistTracks(Iterable<String> keys) =>
_remove('wishlist', keys);
@override
Future<int> removeLovedTracks(Iterable<String> keys) =>
_remove('loved', keys);
@override
Future<int> removeTracksFromPlaylist(String id, Iterable<String> 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<String>)>[];
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,