Files
SpotiFLAC-Mobile/integration_test/collection_remove_batch_test.dart
T

347 lines
12 KiB
Dart

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();
}
});
}
}