mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
350 lines
11 KiB
Dart
350 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:spotiflac_android/models/track.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
|
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/services/motion_artwork_store.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
const album = (album: 'An Album', artist: 'An Artist');
|
|
late Directory root;
|
|
setUp(
|
|
() async => root = await Directory.systemTemp.createTemp('motion-test-'),
|
|
);
|
|
tearDown(() async => root.delete(recursive: true));
|
|
|
|
Future<File> seedLegacyVideo() async {
|
|
final store = MotionArtworkStore(
|
|
directory: () async => root,
|
|
validate: (_) async => true,
|
|
download: (_, output) async {
|
|
await File(output).writeAsBytes([1]);
|
|
return 0.75;
|
|
},
|
|
);
|
|
final saved = await store.save(
|
|
album,
|
|
resolveSource: () async => 'https://example.com/cover.m3u8',
|
|
);
|
|
final file = File.fromUri(Uri.parse(saved!.source));
|
|
final info = root.listSync().whereType<File>().singleWhere(
|
|
(file) => file.path.endsWith('.json'),
|
|
);
|
|
await info.writeAsString('{"aspectRatio":0.75}');
|
|
return file;
|
|
}
|
|
|
|
test('successful transfers with broken video data are not cached', () async {
|
|
final store = MotionArtworkStore(
|
|
directory: () async => root,
|
|
download: (_, output) async {
|
|
await File(output).writeAsBytes([1]);
|
|
return 0.75;
|
|
},
|
|
validate: (_) async => false,
|
|
);
|
|
expect(
|
|
await store.save(
|
|
album,
|
|
resolveSource: () async => 'https://example.com/cover.m3u8',
|
|
),
|
|
isNull,
|
|
);
|
|
expect(root.listSync(), isEmpty);
|
|
});
|
|
|
|
test('corrupt legacy video is replaced only after a valid repair', () async {
|
|
final legacy = await seedLegacyVideo();
|
|
var failDownload = true;
|
|
var validations = 0;
|
|
final store = MotionArtworkStore(
|
|
directory: () async => root,
|
|
validate: (path) async {
|
|
validations++;
|
|
return (await File(path).readAsBytes()).single == 2;
|
|
},
|
|
download: (_, output) async {
|
|
if (failDownload) throw const SocketException('offline');
|
|
await File(output).writeAsBytes([2]);
|
|
return 0.75;
|
|
},
|
|
);
|
|
expect(await store.find(album), isNull);
|
|
expect(await store.find(album), isNull);
|
|
expect(validations, 1);
|
|
Future<String?> resolve() async => 'https://example.com/cover.m3u8';
|
|
expect(await store.save(album, resolveSource: resolve), isNull);
|
|
expect(await legacy.readAsBytes(), [1]);
|
|
failDownload = false;
|
|
final repaired = await store.save(album, resolveSource: resolve);
|
|
expect(repaired?.source, legacy.uri.toString());
|
|
expect(await legacy.readAsBytes(), [2]);
|
|
expect((await store.find(album))?.source, repaired?.source);
|
|
expect(validations, 2);
|
|
expect(
|
|
root.listSync().where((file) => file.path.contains('partial')),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'healthy legacy video stays available offline after validation',
|
|
() async {
|
|
final legacy = await seedLegacyVideo();
|
|
var validations = 0;
|
|
final store = MotionArtworkStore(
|
|
directory: () async => root,
|
|
validate: (_) async {
|
|
validations++;
|
|
return true;
|
|
},
|
|
download: (_, _) async => throw StateError('offline'),
|
|
);
|
|
final results = await Future.wait([store.find(album), store.find(album)]);
|
|
expect(
|
|
results.map((art) => art?.source),
|
|
everyElement(legacy.uri.toString()),
|
|
);
|
|
expect(validations, 1);
|
|
},
|
|
);
|
|
|
|
test('a failed legacy check does not prevent clearing artwork', () async {
|
|
await seedLegacyVideo();
|
|
final store = MotionArtworkStore(
|
|
directory: () async => root,
|
|
validate: (_) async => throw StateError('decoder unavailable'),
|
|
);
|
|
expect(await store.find(album), isNull);
|
|
await store.clear();
|
|
expect(await root.exists(), isFalse);
|
|
await root.create();
|
|
});
|
|
|
|
for (final hasLegacyCopy in [true, false]) {
|
|
test(
|
|
'player never searches or downloads missing artwork (legacy=$hasLegacyCopy)',
|
|
() async {
|
|
final legacy = hasLegacyCopy ? await seedLegacyVideo() : null;
|
|
final store = MotionArtworkStore(
|
|
directory: () async => root,
|
|
validate: (path) async =>
|
|
(await File(path).readAsBytes()).single == 2,
|
|
download: (_, _) async => throw StateError('must not download'),
|
|
);
|
|
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
|
fail('Playback must not call an extension: ${call.method}');
|
|
});
|
|
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
|
final container = ProviderContainer(
|
|
overrides: [motionArtworkStoreProvider.overrideWithValue(store)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final initial = await container.read(
|
|
playerMotionArtworkProvider(album).future,
|
|
);
|
|
expect(initial, isNull);
|
|
expect(container.exists(extensionProvider), isFalse);
|
|
expect(container.exists(settingsProvider), isFalse);
|
|
if (legacy != null) {
|
|
expect(await legacy.readAsBytes(), [1]);
|
|
} else {
|
|
expect(await store.find(album), isNull);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
test(
|
|
'saved video survives restart and app directory relocation offline',
|
|
() async {
|
|
var downloads = 0;
|
|
var location = Directory('${root.path}/old-container');
|
|
final store = MotionArtworkStore(
|
|
directory: () async => location,
|
|
validate: (_) async => true,
|
|
download: (_, output) async {
|
|
downloads++;
|
|
await File(output).writeAsBytes([1, 2, 3, 4]);
|
|
return 0.75;
|
|
},
|
|
);
|
|
final saved = await store.save(
|
|
album,
|
|
resolveSource: () async => 'https://example.com/cover.m3u8',
|
|
);
|
|
expect(saved?.aspectRatio, 0.75);
|
|
expect(await File.fromUri(Uri.parse(saved!.source)).readAsBytes(), [
|
|
1,
|
|
2,
|
|
3,
|
|
4,
|
|
]);
|
|
location = await location.rename('${root.path}/new-container');
|
|
final restarted = MotionArtworkStore(
|
|
directory: () async => location,
|
|
validate: (_) async => throw StateError('already validated'),
|
|
download: (_, _) async => throw StateError('offline'),
|
|
);
|
|
final cached = await restarted.save((
|
|
album: ' AN ALBUM ',
|
|
artist: 'AN ARTIST',
|
|
), resolveSource: () async => throw StateError('offline'));
|
|
expect(cached?.source, contains('new-container'));
|
|
expect(cached?.aspectRatio, 0.75);
|
|
expect(downloads, 1);
|
|
final container = ProviderContainer(
|
|
overrides: [motionArtworkStoreProvider.overrideWithValue(restarted)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final playback = await container.read(
|
|
playerMotionArtworkProvider(album).future,
|
|
);
|
|
expect(playback?.source, cached?.source);
|
|
expect(container.exists(extensionProvider), isFalse);
|
|
},
|
|
);
|
|
|
|
test('concurrent tracks in one album download one complete video', () async {
|
|
final gate = Completer<void>();
|
|
var downloads = 0;
|
|
var lookups = 0;
|
|
final store = MotionArtworkStore(
|
|
directory: () async => root,
|
|
validate: (_) async => true,
|
|
download: (_, output) async {
|
|
downloads++;
|
|
await gate.future;
|
|
await File(output).writeAsBytes([1]);
|
|
return 0.75;
|
|
},
|
|
);
|
|
Future<String?> resolve() async {
|
|
lookups++;
|
|
return 'https://example.com/video.mp4';
|
|
}
|
|
|
|
final first = store.save(album, resolveSource: resolve);
|
|
final second = store.save(album, resolveSource: resolve);
|
|
gate.complete();
|
|
final saved = await Future.wait([first, second]);
|
|
expect(saved.first?.source, saved.last?.source);
|
|
expect(downloads, 1);
|
|
expect(lookups, 1);
|
|
expect(
|
|
root.listSync().whereType<File>().where((f) => f.path.endsWith('.mp4')),
|
|
hasLength(1),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'failed optional transfers leave no partial file and can retry',
|
|
() async {
|
|
var fail = true;
|
|
final store = MotionArtworkStore(
|
|
directory: () async => root,
|
|
validate: (_) async => true,
|
|
download: (_, output) async {
|
|
await File(output).writeAsBytes([1]);
|
|
if (fail) throw const SocketException('offline');
|
|
return 1.0;
|
|
},
|
|
);
|
|
Future<String?> resolve() async => 'https://example.com/video.mp4';
|
|
expect(await store.save(album, resolveSource: resolve), isNull);
|
|
expect(root.listSync(), isEmpty);
|
|
expect(await store.find(album), isNull);
|
|
fail = false;
|
|
expect(await store.save(album, resolveSource: resolve), isNotNull);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'only remote artwork is downloaded and unknown albums keep static art',
|
|
() async {
|
|
final store = MotionArtworkStore(
|
|
directory: () async => root,
|
|
download: (_, _) async => throw StateError('must not download'),
|
|
);
|
|
expect(
|
|
await store.save(
|
|
album,
|
|
resolveSource: () async => 'file:///private/video.mp4',
|
|
),
|
|
isNull,
|
|
);
|
|
expect(
|
|
await store.save((
|
|
album: '',
|
|
artist: '',
|
|
), resolveSource: () async => throw StateError('no album')),
|
|
isNull,
|
|
);
|
|
expect(root.listSync(), isEmpty);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'clearing saved motion waits for writes and keeps the music file',
|
|
() async {
|
|
final music = File('${root.path}/song.flac');
|
|
await music.writeAsBytes([9, 8, 7]);
|
|
final gate = Completer<void>();
|
|
final started = Completer<void>();
|
|
final store = MotionArtworkStore(
|
|
directory: () async => Directory('${root.path}/motion'),
|
|
validate: (_) async => true,
|
|
download: (_, output) async {
|
|
started.complete();
|
|
await gate.future;
|
|
await File(output).writeAsBytes([1]);
|
|
return 0.75;
|
|
},
|
|
);
|
|
final save = store.save(
|
|
album,
|
|
resolveSource: () async => 'https://example.com/cover.mp4',
|
|
);
|
|
await started.future;
|
|
final clear = store.clear();
|
|
expect(
|
|
await store.save(
|
|
album,
|
|
resolveSource: () async => throw StateError('clearing'),
|
|
),
|
|
isNull,
|
|
);
|
|
gate.complete();
|
|
await save;
|
|
await clear;
|
|
expect(await store.find(album), isNull);
|
|
expect(await music.readAsBytes(), [9, 8, 7]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'download queue track serialization retains extension motion artwork',
|
|
() {
|
|
final track = Track.fromBackendMap({
|
|
'id': 'track',
|
|
'name': 'Track',
|
|
'artists': 'Artist',
|
|
'album_name': 'Album',
|
|
'header_video': 'https://example.com/cover.m3u8',
|
|
}, source: 'example-provider');
|
|
final restored = Track.fromJson(track.copyWith(name: 'Updated').toJson());
|
|
expect(restored.headerVideoUrl, 'https://example.com/cover.m3u8');
|
|
expect(restored.source, 'example-provider');
|
|
},
|
|
);
|
|
}
|