import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; 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/collection_track_batch.dart'; import '../test/support/library_collections_benchmark.dart'; import '../test/support/playlist_batch_benchmark.dart'; Future _digest(Object value) => Isolate.run( () => sha256.convert(utf8.encode(jsonEncode(value))).toString(), ); void main() { final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final report = { 'platform': Platform.operatingSystem, 'note': 'Five alternating paired samples. Heartbeat gaps are not FPS.', }; late Directory root; late Database db; final now = DateTime.utc(2026, 10, 8); final stamp = now.toIso8601String(); var completed = 0; setUp(() async { root = await Directory.systemTemp.createTemp('playlist-device-'); final extensions = await Directory('${root.path}/extensions').create(); final data = await Directory('${root.path}/data').create(); final output = await Directory('${root.path}/output').create(); await PlatformBridge.initExtensionSystem( extensions.path, data.path, masterKey: base64Encode(List.filled(32, 1)), lyricsProviders: const [], lyricsFetchOptions: const {}, allowedDirectories: [output.path], ); final implementations = await PlatformBridge.getBackendImplementations(); expect(implementations['extensions'], 'rust'); db = await openDatabase( '${root.path}/library_collections.db', version: 2, onConfigure: (db) async { await db.execute('PRAGMA foreign_keys=ON'); await db.execute('PRAGMA recursive_triggers=ON'); await db.execute('PRAGMA auto_vacuum=INCREMENTAL'); 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 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)', ); for (final id in ['p', 'other']) { await db.insert('playlists', { 'id': id, 'name': id, 'created_at': 'old', 'updated_at': 'old', }); } }, ); }); tearDown(() async { await db.close(); await PlatformBridge.cleanupExtensions(); await root.delete(recursive: true); }); tearDownAll(() { report['completed_cases'] = completed; binding.reportData ??= {}; binding.reportData!['playlist_batch_write'] = report; }); Future publish( PreparedCollectionTrackBatch batch, { String id = 'p', }) async { if (batch.rowsPath == null) { await LibraryCollectionsDatabase.writeDatabasePlaylistTracks( db, playlistId: id, playlistUpdatedAt: stamp, tracks: batch.rows, ); } else { await publishPlaylistBatch( database: db, playlistId: id, updatedAt: stamp, rowsPath: batch.rowsPath!, expectedCount: batch.entries.length, temporaryDirectory: root, ); } } // Preserve the pre-optimization single large sqflite batch as the baseline. Future originalWrite(List> rows) => db.transaction((txn) async { final batch = txn.batch(); for (final row in rows) { batch.insert('playlist_tracks', { 'playlist_id': 'p', ...row, }, conflictAlgorithm: ConflictAlgorithm.replace); } batch.update( 'playlists', {'updated_at': stamp}, where: 'id=?', whereArgs: ['p'], ); await batch.commit(noResult: true); }); for (final count in [20, 256, 1024, 2048, 12000]) { testWidgets('playlist publication parity and paired timing ($count)', ( tester, ) async { final unique = await playlistBatchFixture(count); final input = [...unique, unique.first, unique.last]; final expected = inlinePlaylistBatch(input, const {}, now); final expectedDigest = await _digest(expected.rows); final baseline = >[]; final optimized = >[]; final playlist = UserPlaylistCollection( id: 'p', name: 'p', createdAt: now, updatedAt: now, tracks: const [], ); for (var sample = 0; sample < 5; sample++) { for (final candidate in sample.isEven ? [false, true] : [true, false]) { await db.delete('playlist_tracks'); await db.update('playlists', {'updated_at': 'old'}); final result = await measureCollectionOperation(() async { late List additions; if (candidate) { final batch = await prepareCollectionTrackBatch( tracks: input, existingKeys: playlist.trackKeys, addedAt: now, temporaryDirectory: root, ); try { expect(batch.duplicates, 2); expect(batch.rowsPath != null, count >= 2048); await publish(batch); additions = batch.entries; } finally { await batch.dispose(); } } else { final batch = inlinePlaylistBatch(input, playlist.trackKeys, now); await originalWrite(batch.rows); additions = batch.entries; } // Include the same model publication in both timing paths. return playlist.copyWith( tracks: [...playlist.tracks, ...additions], updatedAt: now, ); }); expect(result.value.trackKeys.length, count); expect( result.value.tracks.map((entry) => entry.key), expected.entries.map((entry) => entry.key), ); final persisted = await db.rawQuery( 'SELECT track_key,track_json,added_at FROM playlist_tracks ORDER BY added_at ASC,rowid ASC', ); expect(await _digest(persisted), expectedDigest); expect(await db.rawQuery('PRAGMA foreign_key_check'), isEmpty); expect((await db.rawQuery('PRAGMA database_list')).length, 1); expect( await db.rawQuery( 'SELECT id,updated_at FROM playlists ORDER BY id', ), [ {'id': 'other', 'updated_at': 'old'}, {'id': 'p', 'updated_at': stamp}, ], ); expect( await root .list() .where( (entry) => entry.path.contains('playlist-input-') || entry.path.contains('playlist-stage-'), ) .isEmpty, isTrue, ); (candidate ? optimized : baseline).add(result.timing); } } report['$count'] = {'baseline': baseline, 'optimized': optimized}; completed++; binding.reportData ??= {}; binding.reportData!['playlist_batch_write'] = report; }); } for (final count in [300, 2100]) { testWidgets('rollback and recovery through actual SQLite ($count)', ( tester, ) async { final batch = await prepareCollectionTrackBatch( tracks: await playlistBatchFixture(count), existingKeys: const {}, addedAt: now, temporaryDirectory: root, ); try { await db.execute( "CREATE TRIGGER reject_update BEFORE UPDATE ON playlists BEGIN SELECT RAISE(ABORT,'fixture rollback'); END", ); await expectLater(publish(batch), throwsA(isA())); expect(await db.rawQuery('SELECT * FROM playlist_tracks'), isEmpty); expect((await db.rawQuery('PRAGMA database_list')).length, 1); await db.execute('DROP TRIGGER reject_update'); await expectLater( publish(batch, id: 'missing'), throwsA(isA()), ); expect(await db.rawQuery('SELECT * FROM playlist_tracks'), isEmpty); await publish(batch); expect( Sqflite.firstIntValue( await db.rawQuery('SELECT COUNT(*) FROM playlist_tracks'), ), count, ); expect(await db.rawQuery('PRAGMA integrity_check'), [ {'integrity_check': 'ok'}, ]); completed++; } finally { await batch.dispose(); } }); } }