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 _images = []; bool _isLoading = false; List get images => _images; bool get isLoading => _isLoading; /// Pictures/MiniTIAS/ 内の PNG と .preview.jpg を取得し,新しい順に並べる. Future 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() .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 deleteImage(File file) async { final path = file.path; final lower = path.toLowerCase(); final filesToDelete = [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.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); }); }); }