perf(collections): batch Love All updates and persistence

This commit is contained in:
zarzet committed 2026-10-08 04:01:50 +07:00
1 parent 460f8ebc2d
commit 20e0573d09
12 files changed
+679 -57

No files matched your search

@@ -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);
}
@@ -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<void> 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<void> deleteLovedEntry(String trackKey) async {
await owner.delete(
'loved_tracks',
where: 'track_key=?',
whereArgs: [trackKey],
);
}
@override
Future<void> upsertLovedTracksBatch(List<Map<String, String>> rows) =>
LibraryCollectionsDatabase.writeDatabaseLovedTracks(owner, rows);
@override
Future<void> deleteLovedTracks(List<String> keys) =>
LibraryCollectionsDatabase.deleteDatabaseLovedTracks(owner, keys);
@override
Future<void> 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 = <String, dynamic>{
'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<int>.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 = <Map<String, dynamic>>[];
final optimized = <Map<String, dynamic>>[];
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);
}
});
}
}
@@ -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<void> publish(PreparedPlaylistBatch batch, {String id = 'p'}) async {
Future<void> 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<CollectionTrackEntry> 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,
@@ -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<LibraryCollectionsState> {
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<Track> 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<bool> toggleFavoriteArtist({
required String artistId,
required String? providerId,
@@ -422,7 +475,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
}
final now = DateTime.now();
final prepared = await preparePlaylistBatch(
final prepared = await prepareCollectionTrackBatch(
tracks: tracks,
existingKeys: playlist.trackKeys,
addedAt: now,
@@ -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<PreparedPlaylistBatch> preparePlaylistBatch({
Future<PreparedCollectionTrackBatch> prepareCollectionTrackBatch({
required Iterable<Track> tracks,
required Set<String> existingKeys,
required DateTime addedAt,
@@ -69,7 +69,7 @@ Future<PreparedPlaylistBatch> preparePlaylistBatch({
}
}
PreparedPlaylistBatch _prepare(
PreparedCollectionTrackBatch _prepare(
List<Track> tracks,
Set<String> existingKeys,
DateTime addedAt,
@@ -118,7 +118,7 @@ PreparedPlaylistBatch _prepare(
});
}
}
return PreparedPlaylistBatch(
return PreparedCollectionTrackBatch(
entries: entries,
duplicates: duplicates,
rows: rows,
@@ -137,7 +137,45 @@ Future<void> publishPlaylistBatch({
required int expectedCount,
Future<Map<String, dynamic>> Function(Map<String, dynamic>)? nativeJobRunner,
Directory? temporaryDirectory,
}) => _publishTrackBatch(
database: database,
playlistId: playlistId,
updatedAt: updatedAt,
rowsPath: rowsPath,
expectedCount: expectedCount,
nativeJobRunner: nativeJobRunner,
temporaryDirectory: temporaryDirectory,
);
Future<void> publishLovedBatch({
required Database database,
required String addedAt,
required String rowsPath,
required int expectedCount,
Future<Map<String, dynamic>> Function(Map<String, dynamic>)? nativeJobRunner,
Directory? temporaryDirectory,
}) => _publishTrackBatch(
database: database,
playlistId: null,
updatedAt: addedAt,
rowsPath: rowsPath,
expectedCount: expectedCount,
nativeJobRunner: nativeJobRunner,
temporaryDirectory: temporaryDirectory,
);
Future<void> _publishTrackBatch({
required Database database,
required String? playlistId,
required String updatedAt,
required String rowsPath,
required int expectedCount,
required Future<Map<String, dynamic>> Function(Map<String, dynamic>)?
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<void> 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<void> 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<void> 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 {
+59 -1
View File
@@ -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<void> deleteLovedEntry(String trackKey) =>
_deleteEntry(_tableLoved, 'track', trackKey);
Future<void> upsertLovedTracksBatch(List<Map<String, String>> tracks) async {
if (tracks.isEmpty) return;
await writeDatabaseLovedTracks(await database, tracks);
}
static Future<void> writeDatabaseLovedTracks(
Database db,
List<Map<String, String>> 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<void> upsertLovedTracksFile({
required String addedAt,
required String rowsPath,
required int expectedCount,
}) async => publishLovedBatch(
database: await database,
addedAt: addedAt,
rowsPath: rowsPath,
expectedCount: expectedCount,
);
Future<void> deleteLovedTracks(List<String> keys) async {
if (keys.isEmpty) return;
await deleteDatabaseLovedTracks(await database, keys);
}
static Future<void> deleteDatabaseLovedTracks(
Database db,
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,
);
}
});
}
Future<void> upsertFavoriteArtistEntry({
required String artistKey,
required String artistJson,
+13 -32
View File
@@ -339,38 +339,19 @@ Future<void> loveAllTracks(
WidgetRef ref,
List<Track> 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)),
),
);
}
),
);
}
}
+54
View File
@@ -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<Track> 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,
+103
View File
@@ -177,6 +177,22 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase {
playlists[playlistId]!['updated_at'] = playlistUpdatedAt;
});
@override
Future<void> upsertLovedTracksBatch(List<Map<String, String>> tracks) =>
_write('bulk-loved:add', () {
for (final row in tracks) {
loved[row['track_key']!] = Map<String, dynamic>.from(row);
}
});
@override
Future<void> deleteLovedTracks(List<String> 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)',
+2 -2
View File
@@ -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),
+82 -1
View File
@@ -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<StateError>()));
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) => <String, String>{
'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<StateError>()),
);
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<StateError>()),
);
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 {
+5 -5
View File
@@ -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,