refactor(metadata): separate file save persistence from editor

This commit is contained in:
zarzet committed 2026-10-06 13:08:13 +07:00
1 parent ae918028bd
commit 4d65b590e1
3 files changed
+578 -183

No files matched your search

+32 -183
View File
@@ -11,9 +11,9 @@ import 'package:spotiflac_android/controllers/metadata_editor_controller.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/metadata_cover_resources.dart';
import 'package:spotiflac_android/services/metadata_autofill_service.dart';
import 'package:spotiflac_android/services/metadata_persistence_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/theme/mornye_icons.dart';
@@ -78,6 +78,7 @@ class _EditMetadataSheetState extends State<EditMetadataSheet> {
bool _saving = false;
final _covers = MetadataCoverResources();
late final _persistence = MetadataPersistenceService(covers: _covers);
final _autofill = MetadataAutofillService();
bool _showAdvanced = false;
bool _showAutoFill = false;
@@ -928,193 +929,41 @@ class _EditMetadataSheetState extends State<EditMetadataSheet> {
Future<void> _save() async {
if (_saving || _fetching || _fetchingMusicBrainz) return;
setState(() => _saving = true);
final releaseCoverLease = _covers.retain();
final metadata = _form.saveMetadata(artistTagMode: widget.artistTagMode);
final noCoverMessage = context.l10n.trackCoverNoEmbeddedArt;
final resizeFailedMessage = context.l10n.trackCoverResizeFailed;
String? resizedCoverTempDir;
String? extractedCoverTempDir;
try {
String? coverPathForSave = _selectedCoverPath;
final requestedCoverDimension = _coverMaxDimension;
if (requestedCoverDimension != null) {
final sourceCoverPath = _selectedCoverPath ?? _currentCoverPath;
if (!_hasValue(sourceCoverPath)) {
throw StateError(noCoverMessage);
}
try {
final resized = await _covers.resize(
sourceCoverPath!,
requestedCoverDimension,
);
resizedCoverTempDir = resized.tempDir;
coverPathForSave = resized.path;
} catch (_) {
throw StateError(resizeFailedMessage);
}
}
metadata['cover_path'] = coverPathForSave ?? '';
final result = await PlatformBridge.editFileMetadata(
widget.filePath,
metadata,
final result = await _persistence.save(
MetadataSaveRequest(
filePath: widget.filePath,
metadata: _form.saveMetadata(artistTagMode: widget.artistTagMode),
artistTagMode: widget.artistTagMode,
selectedCoverPath: _selectedCoverPath,
currentCoverPath: _currentCoverPath,
coverMaxDimension: _coverMaxDimension,
),
);
if (result['error'] != null) {
if (mounted) {
_showSheetSnackBar(
context.friendlyError(
result['error'],
fallback: context.l10n.metadataSaveFailedFfmpeg,
),
);
}
if (!mounted) return;
if (result.failure == null) {
Navigator.pop(context, true);
return;
}
final method = result['method'] as String?;
if (method == 'ffmpeg') {
// For SAF files, Kotlin returns temp_path + saf_uri
final tempPath = result['temp_path'] as String?;
final safUri = result['saf_uri'] as String?;
final ffmpegTarget = tempPath ?? widget.filePath;
final lower = widget.filePath.toLowerCase();
final isMp3 = lower.endsWith('.mp3');
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
// Always include all known fields so -map_metadata 0 + explicit
// -metadata flags can both preserve custom tags AND clear fields
// the user emptied.
final vorbisMap = <String, String>{
'TITLE': metadata['title'] ?? '',
'ARTIST': metadata['artist'] ?? '',
'ALBUM': metadata['album'] ?? '',
'ALBUMARTIST': metadata['album_artist'] ?? '',
'DATE': metadata['date'] ?? '',
'TRACKNUMBER':
(metadata['track_number']?.isNotEmpty == true &&
metadata['track_number'] != '0')
? (metadata['track_total']?.isNotEmpty == true &&
metadata['track_total'] != '0'
? '${metadata['track_number']}/${metadata['track_total']}'
: metadata['track_number']!)
: '',
'DISCNUMBER':
(metadata['disc_number']?.isNotEmpty == true &&
metadata['disc_number'] != '0')
? (metadata['disc_total']?.isNotEmpty == true &&
metadata['disc_total'] != '0'
? '${metadata['disc_number']}/${metadata['disc_total']}'
: metadata['disc_number']!)
: '',
'GENRE': metadata['genre'] ?? '',
'ISRC': metadata['isrc'] ?? '',
'LYRICS': metadata['lyrics'] ?? '',
'UNSYNCEDLYRICS': metadata['lyrics'] ?? '',
'ORGANIZATION': metadata['label'] ?? '',
'COPYRIGHT': metadata['copyright'] ?? '',
'COMPOSER': metadata['composer'] ?? '',
'COMMENT': metadata['comment'] ?? '',
'ITUNESADVISORY': metadata['explicit'] ?? '',
'RELEASETYPE': metadata['album_type'] ?? '',
'BARCODE': metadata['upc'] ?? '',
'COMPILATION': metadata['compilation'] ?? '',
};
try {
final existingMetadata = await PlatformBridge.readFileMetadata(
ffmpegTarget,
);
// Preserve ReplayGain tags if present — these are computed once
// during download and should survive manual metadata edits.
final rgFields = <String, String>{
'REPLAYGAIN_TRACK_GAIN':
existingMetadata['replaygain_track_gain']?.toString() ?? '',
'REPLAYGAIN_TRACK_PEAK':
existingMetadata['replaygain_track_peak']?.toString() ?? '',
'REPLAYGAIN_ALBUM_GAIN':
existingMetadata['replaygain_album_gain']?.toString() ?? '',
'REPLAYGAIN_ALBUM_PEAK':
existingMetadata['replaygain_album_peak']?.toString() ?? '',
};
rgFields.forEach((key, value) {
if (value.isNotEmpty) {
vorbisMap[key] = value;
}
});
} catch (_) {
// Lyrics/ReplayGain preservation is best-effort.
}
String? existingCoverPath = coverPathForSave ?? _currentCoverPath;
if (existingCoverPath == null || existingCoverPath.isEmpty) {
// Preserve current embedded cover when user does not pick a new one.
final extractedCover = await _covers.extract(ffmpegTarget);
existingCoverPath = extractedCover?.path;
extractedCoverTempDir = extractedCover?.tempDir;
}
String? ffmpegResult;
if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
mp3Path: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
preserveMetadata: true,
);
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
preserveMetadata: true,
);
} else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
artistTagMode: widget.artistTagMode,
preserveMetadata: true,
);
}
if (ffmpegResult == null) {
if (mounted) {
_showSheetSnackBar(context.l10n.metadataSaveFailedFfmpeg);
}
return;
}
if (tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok) {
if (mounted) {
_showSheetSnackBar(context.l10n.metadataSaveFailedStorage);
}
return;
}
}
}
if (mounted) {
Navigator.pop(context, true);
}
} catch (e) {
if (mounted) {
_showSheetSnackBar(
context.l10n.snackbarError(context.friendlyError(e)),
);
}
final l10n = context.l10n;
_showSheetSnackBar(switch (result.failure!) {
MetadataSaveFailure.backend => context.friendlyError(
result.error,
fallback: l10n.metadataSaveFailedFfmpeg,
),
MetadataSaveFailure.ffmpeg => l10n.metadataSaveFailedFfmpeg,
MetadataSaveFailure.storage => l10n.metadataSaveFailedStorage,
MetadataSaveFailure.noCover => l10n.snackbarError(
l10n.trackCoverNoEmbeddedArt,
),
MetadataSaveFailure.resize => l10n.snackbarError(
l10n.trackCoverResizeFailed,
),
MetadataSaveFailure.unexpected => l10n.snackbarError(
context.friendlyError(result.error),
),
});
} finally {
await _covers.release(extractedCoverTempDir);
if (resizedCoverTempDir != null) {
await _covers.release(resizedCoverTempDir);
}
await releaseCoverLease();
if (mounted) setState(() => _saving = false);
}
}
@@ -0,0 +1,242 @@
import 'dart:io';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/metadata_cover_resources.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
enum MetadataSaveFailure {
backend,
ffmpeg,
storage,
noCover,
resize,
unexpected,
}
class MetadataSaveResult {
final MetadataSaveFailure? failure;
final Object? error;
const MetadataSaveResult.saved() : failure = null, error = null;
const MetadataSaveResult.failed(
MetadataSaveFailure this.failure, [
this.error,
]);
}
class MetadataSaveRequest {
final String filePath;
final Map<String, String> metadata;
final String artistTagMode;
final String? selectedCoverPath;
final String? currentCoverPath;
final int? coverMaxDimension;
MetadataSaveRequest({
required this.filePath,
required Map<String, String> metadata,
required this.artistTagMode,
this.selectedCoverPath,
this.currentCoverPath,
this.coverMaxDimension,
}) : metadata = Map.unmodifiable(metadata);
}
enum MetadataSaveCodec { mp3, m4a, opus }
typedef MetadataEmbed =
Future<String?> Function(
MetadataSaveCodec codec,
String path,
String? coverPath,
Map<String, String> metadata,
String artistTagMode,
);
/// Writes tags without widget state, and owns resources until every writer ends.
class MetadataPersistenceService {
final MetadataCoverResources covers;
final Future<Map<String, dynamic>> Function(String, Map<String, String>) edit;
final Future<Map<String, dynamic>> Function(String) read;
final MetadataEmbed embed;
final Future<bool> Function(String, String) writeBack;
const MetadataPersistenceService({
required this.covers,
this.edit = PlatformBridge.editFileMetadata,
this.read = PlatformBridge.readFileMetadata,
this.embed = _embed,
this.writeBack = PlatformBridge.writeTempToSaf,
});
Future<MetadataSaveResult> save(MetadataSaveRequest request) async {
final releaseLease = covers.retain();
String? resizedCoverDir;
String? extractedCoverDir;
String? stagedPath;
try {
var coverPath = request.selectedCoverPath;
if (request.coverMaxDimension case final dimension?) {
final source = coverPath ?? request.currentCoverPath;
if (source == null || source.trim().isEmpty) {
return const MetadataSaveResult.failed(MetadataSaveFailure.noCover);
}
try {
final resized = await covers.resize(source, dimension);
resizedCoverDir = resized.tempDir;
coverPath = resized.path;
} catch (error) {
return MetadataSaveResult.failed(MetadataSaveFailure.resize, error);
}
}
final metadata = {
...request.metadata,
'artist_tag_mode': request.artistTagMode,
'cover_path': coverPath ?? '',
};
final result = await edit(request.filePath, metadata);
stagedPath = result['temp_path'] as String?;
if (result['error'] case final error?) {
return MetadataSaveResult.failed(MetadataSaveFailure.backend, error);
}
if (result['method'] != 'ffmpeg') {
return const MetadataSaveResult.saved();
}
final target = stagedPath ?? request.filePath;
final tags = _fallbackTags(metadata);
try {
final existing = await read(target);
for (final key in const [
'REPLAYGAIN_TRACK_GAIN',
'REPLAYGAIN_TRACK_PEAK',
'REPLAYGAIN_ALBUM_GAIN',
'REPLAYGAIN_ALBUM_PEAK',
]) {
final value = existing[key.toLowerCase()]?.toString() ?? '';
if (value.isNotEmpty) tags[key] = value;
}
} catch (_) {
// Computed ReplayGain tags are preserved when the reader is available.
}
var existingCover = coverPath ?? request.currentCoverPath;
if (existingCover == null || existingCover.isEmpty) {
final extracted = await covers.extract(target);
existingCover = extracted?.path;
extractedCoverDir = extracted?.tempDir;
}
final codec = _codec(request.filePath);
final output = codec == null
? null
: await embed(
codec,
target,
existingCover,
tags,
codec == MetadataSaveCodec.opus
? request.artistTagMode
: artistTagModeJoined,
);
if (output == null) {
return const MetadataSaveResult.failed(MetadataSaveFailure.ffmpeg);
}
final safUri = result['saf_uri'] as String?;
if (stagedPath != null &&
safUri != null &&
!await writeBack(output, safUri)) {
return const MetadataSaveResult.failed(MetadataSaveFailure.storage);
}
return const MetadataSaveResult.saved();
} catch (error) {
return MetadataSaveResult.failed(MetadataSaveFailure.unexpected, error);
} finally {
// Android also deletes on writeback; failed FFmpeg never reaches it.
if (stagedPath != null && stagedPath != request.filePath) {
try {
await File(stagedPath).delete();
} catch (_) {}
}
await covers.release(extractedCoverDir);
await covers.release(resizedCoverDir);
await releaseLease();
}
}
static MetadataSaveCodec? _codec(String path) {
final lower = path.toLowerCase();
if (lower.endsWith('.mp3')) return MetadataSaveCodec.mp3;
if (lower.endsWith('.m4a') || lower.endsWith('.aac')) {
return MetadataSaveCodec.m4a;
}
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
return MetadataSaveCodec.opus;
}
return null;
}
static Map<String, String> _fallbackTags(Map<String, String> metadata) {
String index(String key) {
final number = metadata['${key}_number'] ?? '';
final total = metadata['${key}_total'] ?? '';
if (number.isEmpty || number == '0') return '';
return total.isEmpty || total == '0' ? number : '$number/$total';
}
// Emit empty values too: preserve custom tags while clearing edited fields.
return {
for (final entry in const {
'TITLE': 'title',
'ARTIST': 'artist',
'ALBUM': 'album',
'ALBUMARTIST': 'album_artist',
'DATE': 'date',
'GENRE': 'genre',
'ISRC': 'isrc',
'LYRICS': 'lyrics',
'UNSYNCEDLYRICS': 'lyrics',
'ORGANIZATION': 'label',
'COPYRIGHT': 'copyright',
'COMPOSER': 'composer',
'COMMENT': 'comment',
'ITUNESADVISORY': 'explicit',
'RELEASETYPE': 'album_type',
'BARCODE': 'upc',
'COMPILATION': 'compilation',
}.entries)
entry.key: metadata[entry.value] ?? '',
'TRACKNUMBER': index('track'),
'DISCNUMBER': index('disc'),
};
}
static Future<String?> _embed(
MetadataSaveCodec codec,
String path,
String? coverPath,
Map<String, String> metadata,
String artistTagMode,
) => switch (codec) {
MetadataSaveCodec.mp3 => FFmpegService.embedMetadataToMp3(
mp3Path: path,
coverPath: coverPath,
metadata: metadata,
preserveMetadata: true,
),
MetadataSaveCodec.m4a => FFmpegService.embedMetadataToM4a(
m4aPath: path,
coverPath: coverPath,
metadata: metadata,
preserveMetadata: true,
),
MetadataSaveCodec.opus => FFmpegService.embedMetadataToOpus(
opusPath: path,
coverPath: coverPath,
metadata: metadata,
artistTagMode: artistTagMode,
preserveMetadata: true,
),
};
}
+304
View File
@@ -0,0 +1,304 @@
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);
}
},
);
}
}