mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
332 lines
11 KiB
Dart
332 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
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:spotiflac_android/models/settings.dart';
|
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/services/library_database.dart';
|
|
|
|
class _Settings extends SettingsNotifier {
|
|
@override
|
|
AppSettings build() => const AppSettings();
|
|
|
|
@override
|
|
Future<void> ensureLoaded() async {}
|
|
}
|
|
|
|
class _Database implements LibraryDatabase {
|
|
_Database(this.sources);
|
|
|
|
List<LocalLibrarySource> sources;
|
|
Future<List<LocalLibrarySource>> Function()? sourceRead;
|
|
bool failLookup = false;
|
|
|
|
@override
|
|
Future<List<LocalLibrarySource>> getSources() async =>
|
|
sourceRead == null ? List.of(sources) : await sourceRead!();
|
|
|
|
@override
|
|
Future<LocalLibrarySource?> getSourceByPath(String path) async {
|
|
if (failLookup) throw StateError('source lookup failed');
|
|
return sources.where((source) => source.path == path).firstOrNull;
|
|
}
|
|
|
|
@override
|
|
Future<int> getCount() async =>
|
|
sources.where((source) => source.enabled && source.available).length;
|
|
|
|
@override
|
|
Future<LocalLibraryLookupIndex> getLookupIndex() async =>
|
|
const LocalLibraryLookupIndex();
|
|
|
|
@override
|
|
Future<int> getSourceCount(String sourceId) async => 1;
|
|
|
|
@override
|
|
Future<Map<String, int>> getFileModTimes({String? sourceId}) async => {
|
|
'/music/song.flac': 1,
|
|
};
|
|
|
|
@override
|
|
Future<void> removeSource(String sourceId) async {
|
|
sources.removeWhere((source) => source.id == sourceId);
|
|
}
|
|
|
|
@override
|
|
Future<void> updateSourceState(
|
|
String sourceId, {
|
|
bool? enabled,
|
|
bool? available,
|
|
DateTime? lastScannedAt,
|
|
DateTime? lastSeenAt,
|
|
String? lastScanError,
|
|
bool clearLastScanError = false,
|
|
}) async {
|
|
sources = [
|
|
for (final source in sources)
|
|
source.id == sourceId
|
|
? source.copyWith(enabled: enabled, available: available)
|
|
: source,
|
|
];
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
Future<void> _waitFor(bool Function() condition) async {
|
|
for (var i = 0; i < 100 && !condition(); i++) {
|
|
await Future<void>.delayed(Duration.zero);
|
|
}
|
|
expect(condition(), isTrue);
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
const backend = MethodChannel('com.zarz.spotiflac/backend');
|
|
const paths = MethodChannel('plugins.flutter.io/path_provider');
|
|
const notifications = MethodChannel(
|
|
'dexterous.com/flutter/local_notifications',
|
|
);
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
late Directory directory;
|
|
late _Database database;
|
|
late ProviderContainer container;
|
|
late LocalLibraryNotifier library;
|
|
late Map<String, bool> available;
|
|
late LocalLibraryAvailabilityProbe probe;
|
|
late List<(String, Completer<String>)> scans;
|
|
late List<String> calls;
|
|
var completeOnCancel = false;
|
|
|
|
setUp(() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
directory = await Directory.systemTemp.createTemp('library-scheduling-');
|
|
database = _Database([
|
|
for (final id in ['active', 'reconnect'])
|
|
LocalLibrarySource(
|
|
id: id,
|
|
path: '${directory.path}/$id',
|
|
displayName: id,
|
|
trackCount: 1,
|
|
available: id == 'active',
|
|
),
|
|
]);
|
|
available = {
|
|
for (final source in database.sources) source.path: source.available,
|
|
};
|
|
probe = (path, {bookmark}) async => available[path];
|
|
scans = [];
|
|
calls = [];
|
|
completeOnCancel = false;
|
|
messenger.setMockMethodCallHandler(paths, (_) async => directory.path);
|
|
messenger.setMockMethodCallHandler(
|
|
notifications,
|
|
(call) async => call.method == 'initialize' ? true : null,
|
|
);
|
|
messenger.setMockMethodCallHandler(backend, (call) async {
|
|
calls.add(call.method);
|
|
if (call.method == 'scanLibraryFolderIncremental') {
|
|
final result = Completer<String>();
|
|
scans.add(((call.arguments as Map)['folder_path'] as String, result));
|
|
return result.future;
|
|
}
|
|
if (call.method == 'cancelLibraryScan' && completeOnCancel) {
|
|
for (final (_, response) in scans) {
|
|
if (!response.isCompleted) response.complete('{"cancelled":true}');
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
container = ProviderContainer(
|
|
overrides: [
|
|
settingsProvider.overrideWith(_Settings.new),
|
|
localLibraryProvider.overrideWith(
|
|
() => LocalLibraryNotifier(
|
|
database: database,
|
|
availabilityProbe: (path, {bookmark}) =>
|
|
probe(path, bookmark: bookmark),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
library = container.read(localLibraryProvider.notifier);
|
|
await _waitFor(() => !container.read(localLibraryProvider).isLoading);
|
|
});
|
|
|
|
tearDown(() async {
|
|
container.dispose();
|
|
for (final (_, response) in scans) {
|
|
if (!response.isCompleted) response.complete('{"cancelled":true}');
|
|
}
|
|
await Future<void>.delayed(Duration.zero);
|
|
messenger.setMockMethodCallHandler(backend, null);
|
|
messenger.setMockMethodCallHandler(paths, null);
|
|
messenger.setMockMethodCallHandler(notifications, null);
|
|
await directory.delete(recursive: true);
|
|
});
|
|
|
|
Future<void> reconnect() async {
|
|
available['${directory.path}/reconnect'] = true;
|
|
await library.refreshSourceAvailability(scanReconnected: true);
|
|
}
|
|
|
|
test('concurrent starts own one preflight and one native scan', () async {
|
|
final pending = Completer<bool?>();
|
|
var probes = 0;
|
|
probe = (path, {bookmark}) {
|
|
probes++;
|
|
return pending.future;
|
|
};
|
|
final first = library.startSourceScan('active');
|
|
await _waitFor(() => probes == 1);
|
|
await library.startSourceScan('active');
|
|
expect(probes, 1);
|
|
pending.complete(true);
|
|
await _waitFor(() => scans.length == 1);
|
|
scans.single.$2.complete('{"cancelled":true}');
|
|
await first;
|
|
expect(scans, hasLength(1));
|
|
});
|
|
|
|
for (final dispose in [false, true]) {
|
|
test('${dispose ? 'disposal' : 'cancel'} stops owned preflight', () async {
|
|
final pending = Completer<bool?>();
|
|
final started = Completer<void>();
|
|
probe = (path, {bookmark}) {
|
|
started.complete();
|
|
return pending.future;
|
|
};
|
|
final scan = library.startSourceScan('active');
|
|
await started.future;
|
|
if (dispose) {
|
|
container.dispose();
|
|
} else {
|
|
await library.cancelScan();
|
|
}
|
|
pending.complete(true);
|
|
await scan;
|
|
expect(scans, isEmpty);
|
|
if (!dispose) {
|
|
expect(container.read(localLibraryProvider).scanWasCancelled, isTrue);
|
|
probe = (path, {bookmark}) async => available[path];
|
|
final retry = library.startSourceScan('active');
|
|
await _waitFor(() => scans.length == 1);
|
|
scans.single.$2.complete('{"cancelled":true}');
|
|
await retry;
|
|
}
|
|
});
|
|
}
|
|
|
|
for (final remove in [false, true]) {
|
|
for (final duringProbe in [false, true]) {
|
|
test(
|
|
'${remove ? 'remove' : 'disable'} supersedes ${duringProbe ? 'probe' : 'source read'}',
|
|
() async {
|
|
final started = Completer<void>();
|
|
final pending = Completer<bool?>();
|
|
final snapshot = Completer<List<LocalLibrarySource>>();
|
|
final oldSources = List.of(database.sources);
|
|
if (duringProbe) {
|
|
probe = (path, {bookmark}) {
|
|
started.complete();
|
|
return pending.future;
|
|
};
|
|
} else {
|
|
database.sourceRead = () {
|
|
started.complete();
|
|
return snapshot.future;
|
|
};
|
|
}
|
|
final scan = library.startSourceScan('active');
|
|
await started.future;
|
|
database.sourceRead = null;
|
|
if (remove) {
|
|
await library.removeSource('active');
|
|
} else {
|
|
await library.setSourceEnabled('active', false);
|
|
}
|
|
if (duringProbe) {
|
|
pending.complete(true);
|
|
} else {
|
|
snapshot.complete(oldSources);
|
|
}
|
|
await scan;
|
|
expect(scans, isEmpty);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
test('paused scan retains reconnect work until it completes', () async {
|
|
final active = library.startSourceScan('active');
|
|
await _waitFor(() => scans.length == 1);
|
|
await library.pauseScan();
|
|
await reconnect();
|
|
await reconnect();
|
|
expect(scans, hasLength(1));
|
|
await library.resumeScan();
|
|
scans.first.$2.complete('{"cancelled":true}');
|
|
await active;
|
|
await _waitFor(() => scans.length == 2);
|
|
expect(scans.last.$1, '${directory.path}/reconnect');
|
|
scans.last.$2.complete('{"cancelled":true}');
|
|
await _waitFor(() => !container.read(localLibraryProvider).isScanning);
|
|
});
|
|
|
|
test('cancel drops queued and pending-refresh reconnect work', () async {
|
|
final active = library.startSourceScan('active');
|
|
await _waitFor(() => scans.length == 1);
|
|
await reconnect();
|
|
final pending = Completer<bool?>();
|
|
final started = Completer<void>();
|
|
probe = (path, {bookmark}) {
|
|
if (path.endsWith('/reconnect') && !started.isCompleted) {
|
|
started.complete();
|
|
return pending.future;
|
|
}
|
|
return Future<bool?>.value(available[path]);
|
|
};
|
|
// Make a second source transition pending rather than reusing the queue.
|
|
await database.updateSourceState('reconnect', available: false);
|
|
final refresh = library.refreshSourceAvailability(scanReconnected: true);
|
|
await started.future;
|
|
await library.cancelScan();
|
|
pending.complete(true);
|
|
await refresh;
|
|
scans.first.$2.complete('{"cancelled":true}');
|
|
await active;
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(scans, hasLength(1));
|
|
});
|
|
|
|
test('disposing a paused scan cancels native ownership', () async {
|
|
final active = library.startSourceScan('active');
|
|
await _waitFor(() => scans.length == 1);
|
|
await library.pauseScan();
|
|
await reconnect();
|
|
completeOnCancel = true;
|
|
container.dispose();
|
|
await active;
|
|
expect(calls, contains('cancelLibraryScan'));
|
|
expect(scans, hasLength(1));
|
|
});
|
|
|
|
test('setup failure releases admission for a later scan', () async {
|
|
database.failLookup = true;
|
|
await library.startScan('${directory.path}/active');
|
|
database.failLookup = false;
|
|
final retry = library.startSourceScan('active');
|
|
await _waitFor(() => scans.length == 1);
|
|
scans.single.$2.complete(jsonEncode({'cancelled': true}));
|
|
await retry;
|
|
expect(container.read(localLibraryProvider).isScanning, isFalse);
|
|
});
|
|
}
|