Files
SpotiFLAC-Mobile/test/album_completeness_query_test.dart
T
zarzet 2e17963804 refactor(library): isolate schema, row mapping and queue queries
Repair lookup counts during schema migration and resolve local FTS matches through the base table.
2026-10-05 03:28:48 +07:00

404 lines
16 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/queue_tab.dart';
import 'package:spotiflac_android/services/album_completeness.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
String _definition(String file, String table) => RegExp(
'CREATE TABLE $table\\s*\\([\\s\\S]*?\\n\\s*\\)',
).firstMatch(File(file).readAsStringSync())!.group(0)!;
// Execute the actual page/count queries with their bound parameters in SQLite.
// Schema/bootstrap calls are isolated from this fixed inventory fixture.
class _QueryFixture {
Future<List<Map<String, Object?>>> rawQuery(
String sql, [
List<Object?>? arguments,
]) async {
final result = await Process.run('python3', [
'-c',
r'''
import json, sqlite3, sys
data = json.loads(sys.argv[1])
db = sqlite3.connect(':memory:')
db.row_factory = sqlite3.Row
db.execute("ATTACH DATABASE ':memory:' AS history_db")
db.execute(data['history'].replace('CREATE TABLE history', 'CREATE TABLE history_db.history', 1))
db.execute(data['library'])
db.execute('CREATE TABLE library_sources (id TEXT PRIMARY KEY, enabled INTEGER, available INTEGER)')
db.execute("INSERT INTO library_sources VALUES ('internal',1,1)")
db.execute('CREATE VIEW library_visible AS SELECT * FROM library')
for table in ['library_path_keys', 'history_db.history_path_keys']:
db.execute('CREATE TABLE '+table+' (item_id TEXT, path_key TEXT)')
def add(local, id, album, number, total, depth=16):
table = 'library' if local else 'history_db.history'
fields = dict(id=id, track_name='Song '+str(number), artist_name='Artist',
album_name=album, album_artist='Artist', album_key=album+'|artist',
file_path='/'+id+'.flac', track_number=number, total_tracks=total,
disc_number=1, total_discs=1, bit_depth=depth,
search_text=(album+' song '+str(number)+' artist').lower(),
sort_genre='', sort_release='', sort_added=1)
fields.update(dict(scanned_at='2026-01-01', source_id='internal',
track_name_norm='song '+str(number), artist_name_norm='artist', album_name_norm=album,
album_artist_norm='artist') if local else
dict(downloaded_at='2026-01-01', service='example-provider',
sort_track='song '+str(number), sort_artist='artist', sort_album=album,
sort_album_artist='artist'))
db.execute('INSERT INTO '+table+' ('+','.join(fields)+') VALUES ('+','.join('?' for _ in fields)+')', list(fields.values()))
keys = 'library_path_keys' if local else 'history_db.history_path_keys'
db.execute('INSERT INTO '+keys+' VALUES (?,?)', (id, fields['file_path']))
add(False, 'missing1', 'missing', 1, 4, depth=24)
add(False, 'missing2', 'missing', 2, 4)
add(False, 'complete1', 'complete', 1, 2, depth=24)
add(False, 'complete2', 'complete', 2, 2)
add(False, 'unknown1', 'unknown', 1, None)
add(False, 'one1', 'one-track', 1, 12)
add(False, 'mixed1', 'mixed', 1, 2)
add(True, 'mixed2', 'mixed', 2, 2)
add(True, 'local1', 'local-only', 1, 3)
add(True, 'local3', 'local-only', 3, 3)
print(json.dumps([dict(row) for row in db.execute(data['sql'], data['args'])]))
''',
jsonEncode({
'history': _definition('lib/services/history_database.dart', 'history'),
'library': _definition('lib/services/library_schema.dart', 'library'),
'sql': sql,
'args': arguments ?? [],
}),
]);
if (result.exitCode != 0) {
throw StateError('${result.stderr}\nSQL: $sql\nArgs: $arguments');
}
return (jsonDecode(result.stdout as String) as List)
.map((row) => Map<String, Object?>.from(row as Map))
.toList();
}
}
class _Settings extends SettingsNotifier {
@override
AppSettings build() =>
const AppSettings(localLibraryEnabled: true, defaultLibraryView: 'all');
@override
void setHistoryFilterMode(String mode) {
state = state.copyWith(historyFilterMode: mode);
}
}
class _History extends DownloadHistoryNotifier {
@override
DownloadHistoryState build() => DownloadHistoryState(totalCount: 7);
}
class _Local extends LocalLibraryNotifier {
@override
LocalLibraryState build() => LocalLibraryState(totalCount: 3);
}
class _Collections extends LibraryCollectionsNotifier {
@override
LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true);
}
Future<void> _waitFor(WidgetTester tester, Finder finder) async {
for (var attempt = 0; attempt < 40; attempt++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump(const Duration(milliseconds: 50));
if (finder.evaluate().isNotEmpty) return;
}
expect(
finder,
findsWidgets,
reason: find
.byType(Text)
.evaluate()
.map((element) => (element.widget as Text).data)
.join(' | '),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
final previousFactory = databaseFactoryOrNull;
databaseFactory = databaseFactorySqflitePlugin;
addTearDown(() => databaseFactory = previousFactory);
const channel = MethodChannel('plugins.flutter.io/path_provider');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (_) async => '/test/documents');
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
const sqliteChannel = MethodChannel('com.tekartik.sqflite');
final versions = <int, int>{};
final executor = _QueryFixture();
messenger.setMockMethodCallHandler(sqliteChannel, (call) async {
final args = (call.arguments as Map?) ?? {};
if (call.method == 'openDatabase') {
final id = versions.length + 1;
versions[id] = (args['path'] as String).endsWith('history.db')
? HistoryDatabase.schemaVersion
: LibraryDatabase.schemaVersion;
return {'id': id};
}
final sql = args['sql'] as String? ?? '';
if (call.method == 'execute') {
if (sql.contains('CREATE VIRTUAL TABLE')) {
throw PlatformException(
code: 'sqlite_error',
message: 'no such module: fts5',
);
}
return null;
}
if (call.method != 'query') return null;
final rows = sql.toLowerCase().contains('pragma user_version')
? <Map<String, Object?>>[
{'user_version': versions[args['id']]},
]
: await executor.rawQuery(
sql,
(args['arguments'] as List?)?.cast<Object?>(),
);
final columns = rows.isEmpty ? <String>[] : rows.first.keys.toList();
return {
'columns': columns,
'rows': [
for (final row in rows) [for (final column in columns) row[column]],
],
};
});
addTearDown(() => messenger.setMockMethodCallHandler(sqliteChannel, null));
});
test(
'production album/track/count queries filter full inventory and paginate consistently',
() async {
final db = LibraryDatabase.instance;
const query = QueueLibraryDbQuery(
filterMode: 'albums',
metadata: incompleteAlbumFilter,
sortMode: 'album-asc',
);
final albums = await db.getQueueAlbumPage(query);
expect(albums.map((row) => row['album_name']), [
'local-only',
'missing',
'one-track',
]);
expect(albums.map((row) => row['album_present_tracks']), [2, 2, 1]);
expect(albums.map((row) => row['album_expected_tracks']), [3, 4, 12]);
final counts = await db.getQueueCounts(query);
expect(counts.albumCount, 3);
expect(counts.allTrackCount, 5);
final tracks = await db.getQueueTrackPage(
const QueueLibraryDbQuery(metadata: incompleteAlbumFilter),
);
expect(tracks, hasLength(5));
expect(tracks.any((row) => row['albumName'] == 'mixed'), isFalse);
final first = await db.getQueueAlbumPageResult(
const QueueLibraryDbQuery(
filterMode: 'albums',
metadata: incompleteAlbumFilter,
sortMode: 'album-asc',
limit: 1,
),
);
final second = await db.getQueueAlbumPageResult(
QueueLibraryDbQuery(
filterMode: 'albums',
metadata: incompleteAlbumFilter,
sortMode: 'album-asc',
limit: 1,
cursor: first.nextCursor,
),
);
expect(first.rows.single['album_name'], 'local-only');
expect(second.rows.single['album_name'], 'missing');
final unknown = await db.getQueueAlbumPage(
const QueueLibraryDbQuery(
filterMode: 'albums',
metadata: unknownAlbumCompletenessFilter,
),
);
expect(unknown.single['album_name'], 'unknown');
expect(AlbumCompleteness.fromRow(unknown.single)!.badge, '?');
final hiRes = await db.getQueueAlbumPage(
const QueueLibraryDbQuery(
filterMode: 'albums',
metadata: incompleteAlbumFilter,
quality: 'hires',
),
);
expect(hiRes.single['album_name'], 'missing');
expect(hiRes.single['album_present_tracks'], 2);
final searched = await db.getQueueAlbumPage(
const QueueLibraryDbQuery(
filterMode: 'albums',
metadata: incompleteAlbumFilter,
searchQuery: 'one-track',
),
);
expect(searched.single['album_name'], 'one-track');
final local = await db.getQueueAlbumPage(
const QueueLibraryDbQuery(
filterMode: 'albums',
metadata: incompleteAlbumFilter,
source: 'local',
),
);
expect(local.single['album_name'], 'local-only');
final noLocal = await db.getQueueAlbumPage(
const QueueLibraryDbQuery(
filterMode: 'albums',
metadata: incompleteAlbumFilter,
source: 'local',
includeLocal: false,
),
);
expect(noLocal, isEmpty);
final normal = await db.getQueueAlbumPage(
const QueueLibraryDbQuery(filterMode: 'albums'),
);
expect(normal.every((row) => row['album_completeness'] == null), isTrue);
expect(normal.any((row) => row['album_name'] == 'one-track'), isFalse);
},
);
testWidgets(
'Material filter switches to albums, supports search and can be reset',
(tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tabActive = ValueNotifier(true);
addTearDown(tabActive.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
settingsProvider.overrideWith(_Settings.new),
downloadHistoryProvider.overrideWith(_History.new),
localLibraryProvider.overrideWith(_Local.new),
libraryCollectionsProvider.overrideWith(_Collections.new),
downloadQueueLookupProvider.overrideWithValue(
const DownloadQueueLookup.empty(),
),
],
child: MaterialApp(
theme: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: ValueListenableBuilder<bool>(
valueListenable: tabActive,
builder: (_, active, _) => Scaffold(
body: TickerMode(
enabled: active,
child: QueueTab(isTabActive: active),
),
),
),
),
),
);
await _waitFor(tester, find.text('Filters').hitTestable());
await _waitFor(tester, find.text('Song 1').hitTestable());
await tester.tap(find.text('Filters').hitTestable());
await tester.pump(const Duration(milliseconds: 400));
await tester.ensureVisible(find.text('Incomplete albums'));
await _waitFor(tester, find.text('Incomplete albums').hitTestable());
await tester.tap(find.text('Incomplete albums'));
await tester.ensureVisible(find.text('Apply'));
await _waitFor(tester, find.text('Apply').hitTestable());
await tester.tap(find.text('Apply'));
await tester.pump(const Duration(milliseconds: 400));
await _waitFor(tester, find.text('1/12'));
expect(find.text('2/4'), findsOneWidget);
expect(find.text('2/3'), findsOneWidget);
expect(find.text('complete'), findsNothing);
expect(find.byTooltip('2/4 tracks · 2 missing'), findsOneWidget);
final container = ProviderScope.containerOf(
tester.element(find.byType(QueueTab)),
);
expect(container.read(settingsProvider).historyFilterMode, 'albums');
// Pushing an album/detail route disables the underlying route's ticker.
// Popping it must retain Albums rather than reapply the All default.
final navigator = Navigator.of(tester.element(find.byType(QueueTab)));
unawaited(
navigator.push<void>(
MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Text('Album details')),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('Album details'), findsOneWidget);
navigator.pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(container.read(settingsProvider).historyFilterMode, 'albums');
await _waitFor(tester, find.text('1/12'));
await tester.enterText(find.byType(TextField), 'one-track');
await tester.pump(const Duration(milliseconds: 400));
await _waitFor(tester, find.text('1 album'));
expect(find.text('1/12'), findsOneWidget);
expect(find.text('2/4'), findsNothing);
// Even an empty matching result must retain the filter controls.
await tester.enterText(find.byType(TextField), 'no-such-album');
await tester.pump(const Duration(milliseconds: 400));
await _waitFor(tester, find.text('No album downloads'));
expect(find.text('Filters').hitTestable(), findsOneWidget);
await tester.enterText(find.byType(TextField), '');
await tester.pump(const Duration(milliseconds: 400));
await _waitFor(tester, find.text('1/12'));
await tester.tap(find.text('Filters').hitTestable());
await tester.pump(const Duration(milliseconds: 400));
await _waitFor(tester, find.text('Reset').hitTestable());
await tester.tap(find.text('Reset'));
await tester.ensureVisible(find.text('Apply'));
await _waitFor(tester, find.text('Apply').hitTestable());
await tester.tap(find.text('Apply'));
await tester.pump(const Duration(milliseconds: 400));
await _waitFor(tester, find.text('complete'));
expect(find.text('1/12'), findsNothing);
expect(container.read(settingsProvider).historyFilterMode, 'albums');
// An actual shell-tab switch still applies the configured All default.
tabActive.value = false;
await tester.pump();
tabActive.value = true;
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(container.read(settingsProvider).historyFilterMode, 'all');
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
// Let any already-dispatched SQLite queries finish after provider disposal.
for (var attempt = 0; attempt < 10; attempt++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump(const Duration(milliseconds: 50));
}
},
);
}