Files
SpotiFLAC-Mobile/test/metadata_persistence_service_test.dart
T

305 lines
9.9 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/metadata_cover_resources.dart';
import 'package:spotiflac_android/services/metadata_persistence_service.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
class _Covers extends MetadataCoverResources {
bool failResize = false;
bool extractCover = false;
MetadataCoverPreview? resized;
MetadataCoverPreview? extracted;
Future<MetadataCoverPreview> create() =>
copyPicked(extension: 'png', bytes: Uint8List.fromList([1, 2, 3]));
@override
Future<MetadataCoverPreview> resize(String source, int maxDimension) async {
expect(await File(source).exists(), isTrue);
expect(maxDimension, 500);
if (failResize) throw StateError('Resize failed');
return resized = await create();
}
@override
Future<MetadataCoverPreview?> extract(String filePath) async =>
extractCover ? extracted = await create() : null;
}
void main() {
late _Covers covers;
setUp(() => covers = _Covers());
tearDown(() => covers.dispose());
MetadataSaveRequest request({
String path = 'song.mp3',
Map<String, String> tags = const {},
String? selectedCover,
String? currentCover,
int? dimension,
}) => MetadataSaveRequest(
filePath: path,
metadata: tags,
artistTagMode: artistTagModeSplitVorbis,
selectedCoverPath: selectedCover,
currentCoverPath: currentCover,
coverMaxDimension: dimension,
);
test(
'native writer keeps a snapshot and artwork alive after editor closes',
() async {
final cover = await covers.create();
final entered = Completer<void>();
final completed = Completer<Map<String, dynamic>>();
final tags = {'title': 'Song', 'lyrics': ''};
final input = request(tags: tags, selectedCover: cover.path);
tags['title'] = 'Changed';
final service = MetadataPersistenceService(
covers: covers,
edit: (path, metadata) async {
expect(path, 'song.mp3');
expect(metadata, {
'title': 'Song',
'lyrics': '',
'artist_tag_mode': artistTagModeSplitVorbis,
'cover_path': cover.path,
});
entered.complete();
return completed.future;
},
read: (_) async => throw StateError('Native edit needs no fallback'),
);
final pending = service.save(input);
await entered.future;
await covers.dispose();
expect(await File(cover.path).exists(), isTrue);
completed.complete({'success': true, 'method': 'native'});
expect((await pending).failure, isNull);
expect(await Directory(cover.tempDir).exists(), isFalse);
},
);
for (final extension in ['mp3', 'm4a', 'aac', 'opus', 'OGG']) {
test(
'fallback preserves edited/empty tags and artist mode for $extension',
() async {
final cover = await covers.create();
final isOpus = extension == 'opus' || extension == 'OGG';
final service = MetadataPersistenceService(
covers: covers,
edit: (_, metadata) async {
expect(metadata['cover_path'], '');
return {'method': 'ffmpeg'};
},
read: (path) async {
expect(path, 'song.$extension');
return {
'replaygain_track_gain': '-6.20 dB',
'replaygain_track_peak': '0.000000',
'replaygain_album_gain': '0.00 dB',
'replaygain_album_peak': '1.234567',
};
},
embed: (codec, path, coverPath, metadata, mode) async {
expect(
codec,
isOpus
? MetadataSaveCodec.opus
: extension == 'mp3'
? MetadataSaveCodec.mp3
: MetadataSaveCodec.m4a,
);
expect(
mode,
isOpus ? artistTagModeSplitVorbis : artistTagModeJoined,
);
expect(coverPath, cover.path);
expect(metadata['ARTIST'], 'Artist One; Artist Two');
expect(metadata['LYRICS'], 'Existing lyrics');
expect(metadata['UNSYNCEDLYRICS'], 'Existing lyrics');
expect(metadata['TITLE'], '');
expect(metadata['ALBUMARTIST'], '');
expect(metadata['TRACKNUMBER'], '3/12');
expect(metadata['DISCNUMBER'], '');
expect(metadata['REPLAYGAIN_TRACK_GAIN'], '-6.20 dB');
expect(metadata['REPLAYGAIN_TRACK_PEAK'], '0.000000');
expect(metadata['REPLAYGAIN_ALBUM_GAIN'], '0.00 dB');
expect(metadata['REPLAYGAIN_ALBUM_PEAK'], '1.234567');
return path;
},
writeBack: (_, _) async =>
throw StateError('Local edit needs no SAF write'),
);
expect(
(await service.save(
request(
path: 'song.$extension',
currentCover: cover.path,
tags: {
'artist': 'Artist One; Artist Two',
'lyrics': 'Existing lyrics',
'track_number': '3',
'track_total': '12',
'disc_number': '0',
'disc_total': '2',
},
),
)).failure,
isNull,
);
expect(await File(cover.path).exists(), isTrue);
},
);
}
test(
'fallback clears lyrics and handles missing/zero track and disc totals',
() async {
for (final pair in [
('', '12', ''),
('0', '12', ''),
('3', '', '3'),
('3', '0', '3'),
]) {
final service = MetadataPersistenceService(
covers: covers,
edit: (_, _) async => {'method': 'ffmpeg'},
read: (_) async => throw StateError('Reader unavailable'),
embed: (_, path, _, metadata, _) async {
expect(metadata['LYRICS'], '');
expect(metadata['UNSYNCEDLYRICS'], '');
expect(metadata['TRACKNUMBER'], pair.$3);
expect(metadata['DISCNUMBER'], pair.$3);
expect(metadata.keys, isNot(contains('REPLAYGAIN_TRACK_GAIN')));
return path;
},
);
expect(
(await service.save(
request(
tags: {
'track_number': pair.$1,
'track_total': pair.$2,
'disc_number': pair.$1,
'disc_total': pair.$2,
},
),
)).failure,
isNull,
);
}
},
);
test(
'resized artwork replaces the cover and is released after saving',
() async {
final original = await covers.create();
final service = MetadataPersistenceService(
covers: covers,
edit: (_, metadata) async {
expect(metadata['cover_path'], covers.resized!.path);
expect(await File(covers.resized!.path).exists(), isTrue);
return {'method': 'native'};
},
);
expect(
(await service.save(
request(currentCover: original.path, dimension: 500),
)).failure,
isNull,
);
expect(await File(original.path).exists(), isTrue);
expect(await Directory(covers.resized!.tempDir).exists(), isFalse);
},
);
test(
'missing artwork and resize failures return typed failures before editing',
() async {
final service = MetadataPersistenceService(
covers: covers,
edit: (_, _) async => throw StateError('Must not edit'),
);
expect(
(await service.save(request(dimension: 500))).failure,
MetadataSaveFailure.noCover,
);
covers.failResize = true;
final original = await covers.create();
expect(
(await service.save(
request(selectedCover: original.path, dimension: 500),
)).failure,
MetadataSaveFailure.resize,
);
expect(await File(original.path).exists(), isTrue);
},
);
for (final failure in [
null,
MetadataSaveFailure.ffmpeg,
MetadataSaveFailure.storage,
MetadataSaveFailure.unexpected,
MetadataSaveFailure.backend,
]) {
test(
'SAF staged file and extracted artwork are cleaned on $failure',
() async {
final directory = await Directory.systemTemp.createTemp(
'metadata-save-',
);
addTearDown(() => directory.delete(recursive: true));
final staged = await File(
'${directory.path}/staged.mp3',
).writeAsBytes([1, 2, 3]);
var writes = 0;
covers.extractCover = true;
final service = MetadataPersistenceService(
covers: covers,
edit: (_, _) async => {
'method': 'ffmpeg',
'temp_path': staged.path,
'saf_uri': 'content://library/song.mp3',
if (failure == MetadataSaveFailure.backend)
'error': 'Backend failed',
},
read: (_) async => {},
embed: (_, path, coverPath, _, _) async {
expect(path, staged.path);
expect(coverPath, covers.extracted!.path);
if (failure == MetadataSaveFailure.unexpected) {
throw StateError('Writer failed');
}
return failure == MetadataSaveFailure.ffmpeg ? null : path;
},
writeBack: (path, uri) async {
writes++;
expect(path, staged.path);
expect(uri, 'content://library/song.mp3');
expect(await staged.exists(), isTrue);
return failure != MetadataSaveFailure.storage;
},
);
final result = await service.save(
request(path: 'content://library/song.mp3'),
);
expect(result.failure, failure);
expect(
writes,
failure == null || failure == MetadataSaveFailure.storage ? 1 : 0,
);
expect(await staged.exists(), isFalse);
if (covers.extracted case final cover?) {
expect(await Directory(cover.tempDir).exists(), isFalse);
}
},
);
}
}