diff --git a/CLAUDE.md b/CLAUDE.md index 87b7cdd..386cd0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: v1.1.0 リリース整理(ドキュメント再編・Dart/Kotlin リファクタ・画素統計 JUnit テスト追加・SmTIAS-Capture へリネーム) +最新: CameraProvider をテスト可能化(依存注入・撮影フロー特性化テスト・takePicture 分割) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは GUIDE_05「進捗記録の運用ルール(CLAUDE.md / PROGRESS.md)」を参照. ## 必須ルール(コード実装時) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 8f43eb3..1bfbb29 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -88,3 +88,10 @@ - リネーム: 正式名称 SmTIAS に合わせ MiniTIAS → **SmTIAS-Capture**(GitHub リポジトリ名・ローカルフォルダ・Dart パッケージ `smtias_capture`・Android `com.example.smtias_capture`・全プラットフォーム製品名・ドキュメント表記) - 設計判断: SmTIAS-LightSim とのデータ契約(保存接頭辞 `MiniTIAS_QM_`・保存先 `Pictures/MiniTIAS/`)は突合互換のため**意図的に維持** - 検証: dart analyze 0 件 / dart format 差分なし / flutter test 79 件 / flutter build apk --debug 成功 / gradle 単体テスト通過 + +## CameraProvider のテスト可能化(依存注入 + 撮影フロー特性化 + 撮影メソッド分割) + +- 状況: `CameraProvider` の依存サービスをコンストラクタ注入可能にし,撮影フロー(`takePicture`)の保存順序・フラグ遷移・エラー伝播を特性化テストで固定.その安全網の下で肥大化した撮影メソッドをオーケストレーションと責務別 private メソッドに分割 +- 動機: `takePicture` が肥大して可読性が低い一方,依存が直接 new されておりテスト不能だった.「テストがない」のではなく「DI していないからテストが書けない」が根因 +- 設計判断: 分割の前に (1) DI 化 → (2) fake 注入で現状挙動を固定 → (3) 分割,の順で安全網を先に作る方針を採用.実カメラ初期化(`availableCameras`)に到達せず「初期化済み」状態を作るため,`@visibleForTesting` のテスト専用フックを 1 つだけ production に追加(本番からは未呼び出し・本番挙動は不変) +- 発見した既存挙動(後段で要検討・今回は固定のみで未修正): `takePicture` 末尾の `initialize()`(プレビュー再開)が,撮影成功/失敗で設定した `errorMessage` を上書きする.権限が拒否された状態で再初期化に失敗すると,撮影結果のエラー文言が「カメラの権限が必要です」に化ける.実機では権限許可で再初期化が正常進行するため通常は顕在化しないが,研究データ保存の成否表示に関わるため,将来「エラー文言を確定後に再初期化する/再初期化失敗で撮影成否メッセージを潰さない」設計を検討する価値あり diff --git a/lib/providers/camera_provider.dart b/lib/providers/camera_provider.dart index 8dffa5c..7501b1f 100644 --- a/lib/providers/camera_provider.dart +++ b/lib/providers/camera_provider.dart @@ -12,11 +12,24 @@ /// カメラの初期化・プレビュー制御・撮影実行・ライフサイクルを管理する. class CameraProvider extends ChangeNotifier { - final PermissionService _permissionService = PermissionService(); - final FileService _fileService = FileService(); - final RawCaptureService _rawCaptureService = RawCaptureService(); - final SoundService _soundService = SoundService(); - final PreviewRegionService _previewRegionService = PreviewRegionService(); + /// 依存サービスはコンストラクタで差し替え可能(省略時は既定実装を生成). + CameraProvider({ + PermissionService? permissionService, + FileService? fileService, + RawCaptureService? rawCaptureService, + SoundService? soundService, + PreviewRegionService? previewRegionService, + }) : _permissionService = permissionService ?? PermissionService(), + _fileService = fileService ?? FileService(), + _rawCaptureService = rawCaptureService ?? RawCaptureService(), + _soundService = soundService ?? SoundService(), + _previewRegionService = previewRegionService ?? PreviewRegionService(); + + final PermissionService _permissionService; + final FileService _fileService; + final RawCaptureService _rawCaptureService; + final SoundService _soundService; + final PreviewRegionService _previewRegionService; CameraController? _controller; bool _isInitialized = false; @@ -53,6 +66,16 @@ await _soundService.playShutterSound(); } + /// テスト専用: 実カメラ初期化を経ずに「初期化済み」状態を作る. + /// + /// `_controller` は null のままにする(`takePicture()` は `_controller?.` と + /// null 安全に書かれているため、保存フローはそのまま駆動できる). + /// 本番コードからは呼ばないこと. + @visibleForTesting + void debugSetInitializedForTest({bool initialized = true}) { + _isInitialized = initialized; + } + /// カメラを初期化する. /// /// 初期化処理が進行中の場合は即座に return して二重実行を防ぐ. @@ -184,67 +207,10 @@ notifyListeners(); try { - // ストレージ権限の確認 - final storageGranted = await _permissionService.requestStorage(); - if (!storageGranted) { - _errorMessage = 'ストレージの権限が必要です'; - _isSaving = false; - notifyListeners(); - return; - } + if (!await _ensureStoragePermission()) return; - // 画像ストリームを停止してカメラを解放 - await _stopImageStream(); - await _controller?.dispose(); - _controller = null; - - // カメラが完全に解放されるまで待つ - await Future.delayed(const Duration(milliseconds: 500)); - - // 定量モード撮影(DNG 経路) - // baseName を先に確定し,Kotlin 側で DNG を直接書き込む - final baseName = await _fileService.generateQuantitativeBaseName(); - final capture = await _rawCaptureService.captureQuantitativeDng( - baseName: baseName, - directoryPath: _fileService.directoryPath, - ); - debugPrint( - 'DNG saved: ${capture.dngFileSize} bytes at ${capture.dngPath}', - ); - - // メタデータ JSON は引き続き Dart 側で保存 - final metaPath = await _fileService.saveQuantitativeMetadata( - baseName, - capture.metadata, - lscMap: capture.lscMap, - lscMapRowCount: capture.lscMapRowCount, - lscMapColumnCount: capture.lscMapColumnCount, - ); - - // プレビュー JPEG 保存(snapshot がある場合のみ) - String? previewPath; - if (previewBytes != null) { - previewPath = await _fileService.savePreviewJpeg( - baseName, - previewBytes, - ); - debugPrint( - 'Preview saved: ${previewBytes.length} bytes at $previewPath', - ); - } - - // MediaStore に登録(PC から MTP で見えるようにする) - await _rawCaptureService.scanFile(capture.dngPath); - await _rawCaptureService.scanFile(metaPath); - if (previewPath != null) { - await _rawCaptureService.scanFile(previewPath); - } - - _lastSavedFileName = capture.dngPath.split('/').last; - _errorMessage = null; - - // 保存完了音を再生(エラーで撮影フロー全体は止めない) - await _soundService.playSaveCompleteSound(); + await _releaseCameraBeforeCapture(); + await _captureAndSaveQuantitative(previewBytes); } catch (e) { _errorMessage = '撮影に失敗しました: $e'; _lastSavedFileName = null; @@ -257,6 +223,78 @@ await initialize(); } + /// ストレージ権限を確認する.拒否時は errorMessage を設定して通知する. + /// + /// 拒否された場合は [_isSaving] を false に戻して false を返す. + /// 呼び出し側は false を受けたら撮影フローを中断する. + Future _ensureStoragePermission() async { + final storageGranted = await _permissionService.requestStorage(); + if (!storageGranted) { + _errorMessage = 'ストレージの権限が必要です'; + _isSaving = false; + notifyListeners(); + return false; + } + return true; + } + + /// 撮影前に画像ストリームを停止しカメラを解放する. + /// + /// 解放が完全に反映されるまで 500ms 待機してから戻る. + Future _releaseCameraBeforeCapture() async { + await _stopImageStream(); + await _controller?.dispose(); + _controller = null; + + // カメラが完全に解放されるまで待つ + await Future.delayed(const Duration(milliseconds: 500)); + } + + /// 定量モード撮影を行い,DNG・メタデータ・プレビューを保存して登録する. + /// + /// [previewBytes] が非 null のときのみプレビュー JPEG を保存・登録する. + /// 保存した各ファイルを MediaStore へ登録し,DNG ファイル名を + /// [_lastSavedFileName] に反映してから保存完了音を再生する. + Future _captureAndSaveQuantitative(Uint8List? previewBytes) async { + // 定量モード撮影(DNG 経路) + // baseName を先に確定し,Kotlin 側で DNG を直接書き込む + final baseName = await _fileService.generateQuantitativeBaseName(); + final capture = await _rawCaptureService.captureQuantitativeDng( + baseName: baseName, + directoryPath: _fileService.directoryPath, + ); + debugPrint('DNG saved: ${capture.dngFileSize} bytes at ${capture.dngPath}'); + + // メタデータ JSON は引き続き Dart 側で保存 + final metaPath = await _fileService.saveQuantitativeMetadata( + baseName, + capture.metadata, + lscMap: capture.lscMap, + lscMapRowCount: capture.lscMapRowCount, + lscMapColumnCount: capture.lscMapColumnCount, + ); + + // プレビュー JPEG 保存(snapshot がある場合のみ) + String? previewPath; + if (previewBytes != null) { + previewPath = await _fileService.savePreviewJpeg(baseName, previewBytes); + debugPrint('Preview saved: ${previewBytes.length} bytes at $previewPath'); + } + + // MediaStore に登録(PC から MTP で見えるようにする) + await _rawCaptureService.scanFile(capture.dngPath); + await _rawCaptureService.scanFile(metaPath); + if (previewPath != null) { + await _rawCaptureService.scanFile(previewPath); + } + + _lastSavedFileName = capture.dngPath.split('/').last; + _errorMessage = null; + + // 保存完了音を再生(エラーで撮影フロー全体は止めない) + await _soundService.playSaveCompleteSound(); + } + /// カメラリソースを解放する. Future disposeCamera() async { await _stopImageStream(); diff --git a/test/providers/camera_provider_test.dart b/test/providers/camera_provider_test.dart new file mode 100644 index 0000000..72a291c --- /dev/null +++ b/test/providers/camera_provider_test.dart @@ -0,0 +1,785 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:smtias_capture/models/preview_region.dart'; +import 'package:smtias_capture/providers/camera_provider.dart'; +import 'package:smtias_capture/services/file_service.dart'; +import 'package:smtias_capture/services/permission_service.dart'; +import 'package:smtias_capture/services/preview_region_service.dart'; +import 'package:smtias_capture/services/raw_capture_service.dart'; +import 'package:smtias_capture/services/sound_service.dart'; + +/// 共有の呼び出しログ.サービス間の呼び出し順序を記録する. +class _CallLog { + final List entries = []; + + void add(String entry) => entries.add(entry); +} + +/// テスト用 PermissionService. +/// +/// 各権限要求の戻り値を制御し,呼び出し回数を記録する. +class _FakePermissionService extends PermissionService { + _FakePermissionService(this._log); + + final _CallLog _log; + + bool cameraGranted = false; + bool storageGranted = true; + bool cameraPermanentlyDenied = false; + + int requestCameraCallCount = 0; + int requestStorageCallCount = 0; + int openSettingsCallCount = 0; + + @override + Future requestCamera() async { + requestCameraCallCount++; + _log.add('requestCamera'); + return cameraGranted; + } + + @override + Future requestStorage() async { + requestStorageCallCount++; + _log.add('requestStorage'); + return storageGranted; + } + + @override + Future isCameraPermanentlyDenied() async => cameraPermanentlyDenied; + + @override + Future openSettings() async { + openSettingsCallCount++; + _log.add('openSettings'); + } +} + +/// テスト用 FileService. +/// +/// ファイルシステムに触れず,渡された引数と呼び出し順序を記録する. +class _FakeFileService extends FileService { + _FakeFileService(this._log); + + final _CallLog _log; + + String baseNameToReturn = 'MiniTIAS_QM_20260623_120000'; + String metaPathToReturn = '/test/dir/MiniTIAS_QM_20260623_120000.meta.json'; + String previewPathToReturn = + '/test/dir/MiniTIAS_QM_20260623_120000.preview.jpg'; + + int generateBaseNameCallCount = 0; + int saveMetadataCallCount = 0; + int savePreviewCallCount = 0; + + @override + String get directoryPath => '/test/dir'; + + @override + Future generateQuantitativeBaseName() async { + generateBaseNameCallCount++; + _log.add('generateQuantitativeBaseName'); + return baseNameToReturn; + } + + @override + Future saveQuantitativeMetadata( + String baseName, + Map metadata, { + List>? lscMap, + int? lscMapRowCount, + int? lscMapColumnCount, + }) async { + saveMetadataCallCount++; + _log.add('saveQuantitativeMetadata'); + return metaPathToReturn; + } + + @override + Future savePreviewJpeg( + String baseName, + Uint8List previewBytes, + ) async { + savePreviewCallCount++; + _log.add('savePreviewJpeg'); + return previewPathToReturn; + } +} + +/// テスト用 RawCaptureService. +/// +/// プラットフォームチャネルを呼ばず,撮影結果のスタブを返す. +class _FakeRawCaptureService extends RawCaptureService { + _FakeRawCaptureService(this._log); + + final _CallLog _log; + + bool captureShouldThrow = false; + QuantitativeCaptureResult resultToReturn = const QuantitativeCaptureResult( + dngPath: '/test/dir/MiniTIAS_QM_20260623_120000.dng', + dngFileSize: 1024, + metadata: {}, + ); + + int captureCallCount = 0; + final List scannedPaths = []; + + @override + Future captureQuantitativeDng({ + required String baseName, + required String directoryPath, + }) async { + captureCallCount++; + _log.add('captureQuantitativeDng'); + if (captureShouldThrow) { + throw Exception('テスト用キャプチャ失敗'); + } + return resultToReturn; + } + + @override + Future scanFile(String path) async { + scannedPaths.add(path); + _log.add('scanFile'); + } +} + +/// テスト用 SoundService. +/// +/// SharedPreferences・チャネルを呼ばず,呼び出しを記録する. +class _FakeSoundService extends SoundService { + _FakeSoundService(this._log); + + final _CallLog _log; + + int initCallCount = 0; + int saveCompleteCallCount = 0; + int shutterCallCount = 0; + int toggleCallCount = 0; + bool soundEnabled = true; + + @override + bool get isSoundEnabled => soundEnabled; + + @override + Future init() async { + initCallCount++; + _log.add('soundInit'); + } + + @override + Future toggleSound() async { + toggleCallCount++; + soundEnabled = !soundEnabled; + _log.add('toggleSound'); + } + + @override + Future playShutterSound() async { + shutterCallCount++; + _log.add('playShutterSound'); + } + + @override + Future playSaveCompleteSound() async { + saveCompleteCallCount++; + _log.add('playSaveCompleteSound'); + } +} + +/// テスト用 PreviewRegionService. +/// +/// assets を読まず,指定した領域を返す. +class _FakePreviewRegionService extends PreviewRegionService { + _FakePreviewRegionService(this._log); + + final _CallLog _log; + + PreviewRegion regionToReturn = PreviewRegion.full; + int loadCallCount = 0; + + @override + Future load() async { + loadCallCount++; + _log.add('previewRegionLoad'); + return regionToReturn; + } +} + +/// テスト用のサービス一式をまとめた束. +/// +/// 全サービスが同一の [_CallLog] を共有し,跨ぐ呼び出し順序を観測できる. +class _Fakes { + factory _Fakes() { + final log = _CallLog(); + return _Fakes._( + log: log, + permission: _FakePermissionService(log), + file: _FakeFileService(log), + raw: _FakeRawCaptureService(log), + sound: _FakeSoundService(log), + previewRegion: _FakePreviewRegionService(log), + ); + } + + _Fakes._({ + required this.log, + required this.permission, + required this.file, + required this.raw, + required this.sound, + required this.previewRegion, + }); + + final _CallLog log; + final _FakePermissionService permission; + final _FakeFileService file; + final _FakeRawCaptureService raw; + final _FakeSoundService sound; + final _FakePreviewRegionService previewRegion; +} + +CameraProvider _makeProvider(_Fakes f) => CameraProvider( + permissionService: f.permission, + fileService: f.file, + rawCaptureService: f.raw, + soundService: f.sound, + previewRegionService: f.previewRegion, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('CameraProvider 初期状態', () { + test('初期状態では isInitialized が false である', () { + final f = _Fakes(); + final provider = _makeProvider(f); + expect(provider.isInitialized, isFalse); + }); + + test('初期状態では isSaving が false である', () { + final f = _Fakes(); + final provider = _makeProvider(f); + expect(provider.isSaving, isFalse); + }); + + test('初期状態では lastSavedFileName が null である', () { + final f = _Fakes(); + final provider = _makeProvider(f); + expect(provider.lastSavedFileName, isNull); + }); + + test('初期状態では errorMessage が null である', () { + final f = _Fakes(); + final provider = _makeProvider(f); + expect(provider.errorMessage, isNull); + }); + + test('初期状態では previewRegion がフル領域である', () { + final f = _Fakes(); + final provider = _makeProvider(f); + expect(provider.previewRegion.isFull, isTrue); + }); + + test('初期状態では permissionDenied が false である', () { + final f = _Fakes(); + final provider = _makeProvider(f); + expect(provider.permissionDenied, isFalse); + }); + + test('isSoundEnabled は注入した SoundService の値を反映する', () { + final f = _Fakes(); + f.sound.soundEnabled = false; + final provider = _makeProvider(f); + expect(provider.isSoundEnabled, isFalse); + }); + }); + + group('CameraProvider.takePicture ガード条件(未初期化)', () { + test('未初期化のとき,takePicture は captureQuantitativeDng を呼ばない', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8]), + ); + + expect(f.raw.captureCallCount, 0); + }); + + test('未初期化のとき,takePicture は generateQuantitativeBaseName を呼ばない', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + await provider.takePicture(); + + expect(f.file.generateBaseNameCallCount, 0); + }); + + test('未初期化のとき,takePicture は requestStorage を呼ばない', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + await provider.takePicture(); + + expect(f.permission.requestStorageCallCount, 0); + }); + + test('未初期化のとき,takePicture は何も保存せず isSaving は false のままである', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + await provider.takePicture(); + + expect(provider.isSaving, isFalse); + expect(f.file.saveMetadataCallCount, 0); + expect(f.file.savePreviewCallCount, 0); + expect(f.raw.scannedPaths, isEmpty); + expect(f.sound.saveCompleteCallCount, 0); + }); + + test('未初期化のとき,takePicture は lastSavedFileName を変更しない', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + await provider.takePicture(); + + expect(provider.lastSavedFileName, isNull); + }); + + test('未初期化のとき,takePicture は errorMessage を設定しない', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + await provider.takePicture(); + + expect(provider.errorMessage, isNull); + }); + + test('未初期化のとき,takePicture はサービスを一切呼ばない(ログが空)', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + await provider.takePicture(); + + expect(f.log.entries, isEmpty); + }); + }); + + group('CameraProvider.toggleSound', () { + test('toggleSound は SoundService.toggleSound に委譲する', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + await provider.toggleSound(); + + expect(f.sound.toggleCallCount, 1); + }); + + test('toggleSound 後に isSoundEnabled が反転する', () async { + final f = _Fakes(); + f.sound.soundEnabled = true; + final provider = _makeProvider(f); + + await provider.toggleSound(); + + expect(provider.isSoundEnabled, isFalse); + }); + + test('toggleSound はリスナーへ通知する', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + var notified = 0; + provider.addListener(() => notified++); + + await provider.toggleSound(); + + expect(notified, greaterThanOrEqualTo(1)); + }); + }); + + group('CameraProvider.playShutterSound', () { + test('playShutterSound は SoundService.playShutterSound に委譲する', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + await provider.playShutterSound(); + + expect(f.sound.shutterCallCount, 1); + }); + }); + + group('CameraProvider.openSettings', () { + test('openSettings は PermissionService.openSettings に委譲する', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + await provider.openSettings(); + + expect(f.permission.openSettingsCallCount, 1); + }); + }); + + group('CameraProvider.capturePreviewSnapshot', () { + test('最新フレームがない場合,null を返す', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + final result = await provider.capturePreviewSnapshot(); + + expect(result, isNull); + }); + }); + + group('CameraProvider.disposeCamera', () { + test('disposeCamera 後に isInitialized が false である', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + + await provider.disposeCamera(); + + expect(provider.isInitialized, isFalse); + }); + + test('disposeCamera はリスナーへ通知する', () async { + final f = _Fakes(); + final provider = _makeProvider(f); + var notified = 0; + provider.addListener(() => notified++); + + await provider.disposeCamera(); + + expect(notified, greaterThanOrEqualTo(1)); + }); + }); + + group('CameraProvider.initialize(権限拒否で early-return)', () { + test('カメラ権限が拒否されると errorMessage に「カメラの権限が必要です」が入る', () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + + await provider.initialize(); + + expect(provider.errorMessage, 'カメラの権限が必要です'); + }); + + test('カメラ権限が拒否されると permissionDenied が true になる', () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + + await provider.initialize(); + + expect(provider.permissionDenied, isTrue); + }); + + test('カメラ権限が拒否されると isInitialized は false のままである', () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + + await provider.initialize(); + + expect(provider.isInitialized, isFalse); + }); + + test( + 'initialize は SoundService.init と PreviewRegionService.load を呼ぶ', + () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + + await provider.initialize(); + + expect(f.sound.initCallCount, 1); + expect(f.previewRegion.loadCallCount, 1); + }, + ); + + test( + 'initialize は PreviewRegionService が返した領域を previewRegion に反映する', + () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + f.previewRegion.regionToReturn = const PreviewRegion( + left: 0.1, + top: 0.2, + right: 0.8, + bottom: 0.9, + ); + final provider = _makeProvider(f); + + await provider.initialize(); + + expect(provider.previewRegion.left, 0.1); + expect(provider.previewRegion.isFull, isFalse); + }, + ); + + test('権限永久拒否のとき permissionPermanentlyDenied が true になる', () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + f.permission.cameraPermanentlyDenied = true; + final provider = _makeProvider(f); + + await provider.initialize(); + + expect(provider.permissionPermanentlyDenied, isTrue); + }); + }); + + group('CameraProvider.takePicture 保存フロー(初期化済み)', () { + // debugSetInitializedForTest() でガードを通過させ,保存フロー本体を駆動する. + // takePicture() 末尾の initialize() は requestCamera=false で early-return させ, + // 実カメラ(availableCameras())に到達させない. + + test('previewBytes あり: 保存処理が定義順で 1 回ずつ呼ばれる', () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xE0]), + ); + + // takePicture 本体の保存シーケンスのみを抽出して順序を固定する. + // (末尾 initialize() 由来の soundInit/previewRegionLoad/requestCamera は除外) + const saveFlow = { + 'requestStorage', + 'generateQuantitativeBaseName', + 'captureQuantitativeDng', + 'saveQuantitativeMetadata', + 'savePreviewJpeg', + 'scanFile', + 'playSaveCompleteSound', + }; + final actual = f.log.entries.where(saveFlow.contains).toList(); + + expect(actual, [ + 'requestStorage', + 'generateQuantitativeBaseName', + 'captureQuantitativeDng', + 'saveQuantitativeMetadata', + 'savePreviewJpeg', + 'scanFile', // dngPath + 'scanFile', // metaPath + 'scanFile', // previewPath + 'playSaveCompleteSound', + ]); + }); + + test( + 'previewBytes あり: scanFile が dng → meta → preview の順で 3 回呼ばれる', + () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8]), + ); + + expect(f.raw.scannedPaths, [ + f.raw.resultToReturn.dngPath, + f.file.metaPathToReturn, + f.file.previewPathToReturn, + ]); + }, + ); + + test('previewBytes あり: 各保存サービスがちょうど 1 回呼ばれる', () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8]), + ); + + expect(f.file.generateBaseNameCallCount, 1); + expect(f.raw.captureCallCount, 1); + expect(f.file.saveMetadataCallCount, 1); + expect(f.file.savePreviewCallCount, 1); + expect(f.sound.saveCompleteCallCount, 1); + }); + + test( + 'previewBytes が null: savePreviewJpeg を呼ばず scanFile は 2 回のみ', + () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + await provider.takePicture(); + + expect(f.file.savePreviewCallCount, 0); + expect(f.raw.scannedPaths, [ + f.raw.resultToReturn.dngPath, + f.file.metaPathToReturn, + ]); + }, + ); + + test('正常系完了後: isSaving が false に戻る', () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8]), + ); + + expect(provider.isSaving, isFalse); + }); + + test('正常系: 保存完了通知の時点で errorMessage が null になる', () async { + // 現状挙動: takePicture 本体は成功時 errorMessage=null を設定し + // notifyListeners() するが,直後に末尾 initialize() が cameraGranted=false で + // errorMessage を 'カメラの権限が必要です' に上書きする. + // そのため await 完了後の最終値ではなく,保存完了通知時点の履歴で確認する. + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + final errorHistory = []; + provider.addListener(() => errorHistory.add(provider.errorMessage)); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8]), + ); + + // 撮影成功 → errorMessage=null の通知が一度は発生する. + expect(errorHistory, contains(null)); + }); + + test('正常系完了後: 末尾 initialize() が errorMessage を上書きする(現状挙動)', () async { + // 特性化: 保存成功後も末尾 initialize() が requestCamera=false で + // early-return し errorMessage を 'カメラの権限が必要です' に上書きする. + // takePicture の最終状態としてはこのメッセージが残る(現状の実装挙動). + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8]), + ); + + expect(provider.errorMessage, 'カメラの権限が必要です'); + }); + + test('正常系完了後: lastSavedFileName が dngPath のファイル名末尾に一致する', () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + f.raw.resultToReturn = const QuantitativeCaptureResult( + dngPath: '/test/dir/MiniTIAS_QM_20260623_153000.dng', + dngFileSize: 2048, + metadata: {}, + ); + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8]), + ); + + expect(provider.lastSavedFileName, 'MiniTIAS_QM_20260623_153000.dng'); + }); + + test('保存中に isSaving が true になる(リスナー観測)', () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + final savingHistory = []; + provider.addListener(() => savingHistory.add(provider.isSaving)); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8]), + ); + + // 通知履歴のどこかで true(保存中)が観測され,最終的に false に戻る. + expect(savingHistory, contains(true)); + expect(provider.isSaving, isFalse); + }); + + test('ストレージ権限拒否: errorMessage が「ストレージの権限が必要です」になる', () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + f.permission.storageGranted = false; + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8]), + ); + + expect(provider.errorMessage, 'ストレージの権限が必要です'); + expect(f.raw.captureCallCount, 0); + expect(provider.isSaving, isFalse); + }); + + test( + 'キャプチャ例外: 撮影フローが「撮影に失敗しました」を設定し lastSavedFileName が null になる', + () async { + final f = _Fakes(); + f.permission.cameraGranted = false; + f.raw.captureShouldThrow = true; + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + // catch 節での errorMessage 設定は,末尾 initialize() の前の + // notifyListeners() で通知される.その通知時点のスナップショットを観測する. + // 現状挙動: 末尾 initialize() が cameraGranted=false で errorMessage を + // 'カメラの権限が必要です' に上書きするため,await 完了後の最終値ではなく + // 通知履歴で撮影失敗メッセージを確認する(発見した問題の項を参照). + final errorHistory = []; + provider.addListener(() => errorHistory.add(provider.errorMessage)); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8]), + ); + + expect( + errorHistory.any((m) => m != null && m.contains('撮影に失敗しました')), + isTrue, + reason: '撮影フローの catch 節で「撮影に失敗しました」が一度は設定されるべき', + ); + expect(provider.lastSavedFileName, isNull); + expect(provider.isSaving, isFalse); + // 例外発生後はメタデータ保存・preview 保存・scan に到達しない. + expect(f.file.saveMetadataCallCount, 0); + expect(f.file.savePreviewCallCount, 0); + expect(f.raw.scannedPaths, isEmpty); + }, + ); + + test('キャプチャ例外後: 末尾 initialize() が errorMessage を上書きする(現状挙動)', () async { + // 特性化: catch で '撮影に失敗しました' を設定後,takePicture 末尾の + // initialize() が requestCamera=false で early-return し errorMessage を + // 'カメラの権限が必要です' に上書きする.現状の実装挙動を固定する. + final f = _Fakes(); + f.permission.cameraGranted = false; + f.raw.captureShouldThrow = true; + final provider = _makeProvider(f); + provider.debugSetInitializedForTest(); + + await provider.takePicture( + previewBytes: Uint8List.fromList([0xFF, 0xD8]), + ); + + expect(provider.errorMessage, 'カメラの権限が必要です'); + }); + }); +}