import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
/// テスト専用の GalleryProvider 実装.
///
/// プロダクションの GalleryProvider はパスがハードコードされており
/// サブクラスでオーバーライドできないため,同じロジックをテスト用パスで
/// 再実装する.仕様(SPEC_01)に準拠した振る舞いをテストする.
class _GalleryProviderTestable extends ChangeNotifier {
_GalleryProviderTestable(this._basePath);
final String _basePath;
List<File> _images = [];
bool _isLoading = false;
List<File> get images => _images;
bool get isLoading => _isLoading;
/// Pictures/MiniTIAS/ 内の PNG と .preview.jpg を取得し,新しい順に並べる.
Future<void> loadImages() async {
_isLoading = true;
notifyListeners();
try {
final directory = Directory(_basePath);
if (!await directory.exists()) {
_images = [];
} else {
final files = await directory
.list()
.where((entity) {
final lower = entity.path.toLowerCase();
return entity is File &&
(lower.endsWith('.png') || lower.endsWith('.preview.jpg'));
})
.cast<File>()
.toList();
// 更新日時の新しい順にソート
files.sort((a, b) {
final aStat = a.statSync();
final bStat = b.statSync();
return bStat.modified.compareTo(aStat.modified);
});
_images = files;
}
} catch (e) {
debugPrint('画像一覧の取得に失敗: $e');
_images = [];
}
_isLoading = false;
notifyListeners();
}
/// 指定したファイルを削除し,一覧から除去する.
///
/// QM プレビュー (*.preview.jpg) を削除する場合,同じ baseName の
/// .dng ファイルと .meta.json ファイルも連動して削除する.
/// .png ファイルは単独削除(後方互換).
Future<bool> deleteImage(File file) async {
final path = file.path;
final lower = path.toLowerCase();
final filesToDelete = <File>[file];
if (lower.endsWith('.preview.jpg')) {
final base = path.substring(0, path.length - '.preview.jpg'.length);
filesToDelete.add(File('$base.dng'));
filesToDelete.add(File('$base.meta.json'));
}
try {
for (final f in filesToDelete) {
if (await f.exists()) {
await f.delete();
}
}
if (!await file.exists()) {
_images.remove(file);
notifyListeners();
return true;
}
return false;
} catch (e) {
debugPrint('画像の削除に失敗: $e');
return false;
}
}
}
void main() {
group('GalleryProvider.loadImages', () {
late Directory tempDir;
late _GalleryProviderTestable provider;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp(
'mini_tias_gallery_test_',
);
provider = _GalleryProviderTestable(tempDir.path);
});
tearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
test('.png のみある場合,全件取得する', () async {
await File('${tempDir.path}/MiniTIAS_20260101_120000.png').create();
await File('${tempDir.path}/MiniTIAS_20260101_120001.png').create();
await provider.loadImages();
final names = provider.images
.map((f) => f.path.split(RegExp(r'[\\/]')).last)
.toList();
expect(names, contains('MiniTIAS_20260101_120000.png'));
expect(names, contains('MiniTIAS_20260101_120001.png'));
expect(provider.images, hasLength(2));
});
test('.preview.jpg のみある場合,全件取得する', () async {
await File(
'${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg',
).create();
await File(
'${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg',
).create();
await provider.loadImages();
final names = provider.images
.map((f) => f.path.split(RegExp(r'[\\/]')).last)
.toList();
expect(names, contains('MiniTIAS_QM_20260101_120000.preview.jpg'));
expect(names, contains('MiniTIAS_QM_20260101_120001.preview.jpg'));
expect(provider.images, hasLength(2));
});
test('.png と .preview.jpg が混在する場合,両方取得する', () async {
await File('${tempDir.path}/MiniTIAS_20260101_120000.png').create();
await File(
'${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg',
).create();
await provider.loadImages();
final names = provider.images
.map((f) => f.path.split(RegExp(r'[\\/]')).last)
.toList();
expect(names, contains('MiniTIAS_20260101_120000.png'));
expect(names, contains('MiniTIAS_QM_20260101_120001.preview.jpg'));
expect(provider.images, hasLength(2));
});
test('.dng や .meta.json は取得しない', () async {
await File('${tempDir.path}/MiniTIAS_QM_20260101_120000.dng').create();
await File(
'${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json',
).create();
await provider.loadImages();
expect(provider.images, isEmpty);
});
test('通常 .jpg(.preview.jpg 以外)は取得しない', () async {
// .preview.jpg のみが対象,単純な .jpg は除外される
await File('${tempDir.path}/photo.jpg').create();
await File('${tempDir.path}/MiniTIAS_20260101_120000.jpg').create();
// 比較用に .preview.jpg を 1 件追加
await File(
'${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg',
).create();
await provider.loadImages();
final names = provider.images
.map((f) => f.path.split(RegExp(r'[\\/]')).last)
.toList();
expect(names, isNot(contains('photo.jpg')));
expect(names, isNot(contains('MiniTIAS_20260101_120000.jpg')));
expect(names, contains('MiniTIAS_QM_20260101_120001.preview.jpg'));
expect(provider.images, hasLength(1));
});
test('ディレクトリが存在しない場合,空リストを返す', () async {
final nonExistentPath = '${tempDir.path}/nonexistent';
final providerNonExistent = _GalleryProviderTestable(nonExistentPath);
await providerNonExistent.loadImages();
expect(providerNonExistent.images, isEmpty);
});
test('ファイルが新しい順(更新日時降順)にソートされる', () async {
// 先に古いファイルを作成し,十分な時間差を設けてから新しいファイルを作成する
final olderPath = '${tempDir.path}/MiniTIAS_20260101_120000.png';
final newerPath =
'${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg';
await File(olderPath).create();
// Windows NTFS の mtime 精度 (100ns) より十分大きい 1 秒以上待機
await Future<void>.delayed(const Duration(seconds: 2));
await File(newerPath).create();
await provider.loadImages();
expect(provider.images, hasLength(2));
// 新しい順なので newer が先頭
final firstBasename = provider.images.first.path
.split(RegExp(r'[\\/]'))
.last;
expect(firstBasename, 'MiniTIAS_QM_20260101_120001.preview.jpg');
});
});
group('GalleryProvider.deleteImage', () {
late Directory tempDir;
late _GalleryProviderTestable provider;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('mini_tias_del_test_');
provider = _GalleryProviderTestable(tempDir.path);
});
tearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
test('.preview.jpg を削除すると同 basename の .dng も削除される', () async {
final previewPath =
'${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg';
final dngFile = File('${tempDir.path}/MiniTIAS_QM_20260101_120000.dng');
final metaFile = File(
'${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json',
);
await File(previewPath).create();
await dngFile.create();
await metaFile.create();
await provider.loadImages();
// loadImages で取得した同一インスタンスを使って削除する
final fileToDelete = provider.images.first;
final result = await provider.deleteImage(fileToDelete);
expect(result, isTrue);
expect(await dngFile.exists(), isFalse);
});
test('.preview.jpg を削除すると同 basename の .meta.json も削除される', () async {
final previewPath =
'${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg';
final dngFile = File('${tempDir.path}/MiniTIAS_QM_20260101_120000.dng');
final metaFile = File(
'${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json',
);
await File(previewPath).create();
await dngFile.create();
await metaFile.create();
await provider.loadImages();
final fileToDelete = provider.images.first;
final result = await provider.deleteImage(fileToDelete);
expect(result, isTrue);
expect(await metaFile.exists(), isFalse);
});
test('.png を削除しても他のファイルは削除されない', () async {
final pngPath = '${tempDir.path}/MiniTIAS_20260101_120000.png';
// 同名の .dng が仮に存在しても,連動削除の対象にならない
final dngFile = File('${tempDir.path}/MiniTIAS_20260101_120000.dng');
await File(pngPath).create();
await dngFile.create();
await provider.loadImages();
final fileToDelete = provider.images.first;
final result = await provider.deleteImage(fileToDelete);
expect(result, isTrue);
// .dng は削除されていない
expect(await dngFile.exists(), isTrue);
});
test('連動削除対象の .dng が最初から存在しない場合でもエラーにならず .preview.jpg の削除は成功する', () async {
final previewPath =
'${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg';
final metaFile = File(
'${tempDir.path}/MiniTIAS_QM_20260101_120000.meta.json',
);
// .dng は作成しない
await File(previewPath).create();
await metaFile.create();
await provider.loadImages();
final fileToDelete = provider.images.first;
// .dng が存在しなくても例外にならない
final result = await provider.deleteImage(fileToDelete);
expect(result, isTrue);
expect(await File(previewPath).exists(), isFalse);
});
test('削除成功後,images リストから対象ファイルが除去される(.preview.jpg)', () async {
final previewPath =
'${tempDir.path}/MiniTIAS_QM_20260101_120000.preview.jpg';
await File(previewPath).create();
await provider.loadImages();
expect(provider.images, hasLength(1));
// loadImages で保持している同一インスタンスを使って削除する
final fileToDelete = provider.images.first;
final result = await provider.deleteImage(fileToDelete);
expect(result, isTrue);
expect(provider.images, isEmpty);
});
test('削除成功後,images リストから対象ファイルが除去される(.png)', () async {
final pngPath = '${tempDir.path}/MiniTIAS_20260101_120000.png';
await File(pngPath).create();
await provider.loadImages();
expect(provider.images, hasLength(1));
final fileToDelete = provider.images.first;
final result = await provider.deleteImage(fileToDelete);
expect(result, isTrue);
expect(provider.images, isEmpty);
});
});
}