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(); // 各テストごとに新しいフェイク一式を生成する(setUp は各テスト前に実行される // ため,従来の `final f = _Fakes();` と同じく独立したインスタンスが得られる). // プロバイダ生成前のフェイク事前設定はテストごとに異なるため,プロバイダ生成 // (_makeProvider)は各テスト内に残す. late _Fakes f; setUp(() { f = _Fakes(); }); group('CameraProvider 初期状態', () { test('初期状態では isInitialized が false である', () { final provider = _makeProvider(f); expect(provider.isInitialized, isFalse); }); test('初期状態では isSaving が false である', () { final provider = _makeProvider(f); expect(provider.isSaving, isFalse); }); test('初期状態では lastSavedFileName が null である', () { final provider = _makeProvider(f); expect(provider.lastSavedFileName, isNull); }); test('初期状態では errorMessage が null である', () { final provider = _makeProvider(f); expect(provider.errorMessage, isNull); }); test('初期状態では previewRegion がフル領域である', () { final provider = _makeProvider(f); expect(provider.previewRegion.isFull, isTrue); }); test('初期状態では permissionDenied が false である', () { final provider = _makeProvider(f); expect(provider.permissionDenied, isFalse); }); test('isSoundEnabled は注入した SoundService の値を反映する', () { f.sound.soundEnabled = false; final provider = _makeProvider(f); expect(provider.isSoundEnabled, isFalse); }); }); group('CameraProvider.takePicture ガード条件(未初期化)', () { test('未初期化のとき,takePicture は captureQuantitativeDng を呼ばない', () async { final provider = _makeProvider(f); await provider.takePicture( previewBytes: Uint8List.fromList([0xFF, 0xD8]), ); expect(f.raw.captureCallCount, 0); }); test('未初期化のとき,takePicture は generateQuantitativeBaseName を呼ばない', () async { final provider = _makeProvider(f); await provider.takePicture(); expect(f.file.generateBaseNameCallCount, 0); }); test('未初期化のとき,takePicture は requestStorage を呼ばない', () async { final provider = _makeProvider(f); await provider.takePicture(); expect(f.permission.requestStorageCallCount, 0); }); test('未初期化のとき,takePicture は何も保存せず isSaving は false のままである', () async { 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 provider = _makeProvider(f); await provider.takePicture(); expect(provider.lastSavedFileName, isNull); }); test('未初期化のとき,takePicture は errorMessage を設定しない', () async { final provider = _makeProvider(f); await provider.takePicture(); expect(provider.errorMessage, isNull); }); test('未初期化のとき,takePicture はサービスを一切呼ばない(ログが空)', () async { final provider = _makeProvider(f); await provider.takePicture(); expect(f.log.entries, isEmpty); }); }); group('CameraProvider.toggleSound', () { test('toggleSound は SoundService.toggleSound に委譲する', () async { final provider = _makeProvider(f); await provider.toggleSound(); expect(f.sound.toggleCallCount, 1); }); test('toggleSound 後に isSoundEnabled が反転する', () async { f.sound.soundEnabled = true; final provider = _makeProvider(f); await provider.toggleSound(); expect(provider.isSoundEnabled, isFalse); }); test('toggleSound はリスナーへ通知する', () async { 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 provider = _makeProvider(f); await provider.playShutterSound(); expect(f.sound.shutterCallCount, 1); }); }); group('CameraProvider.openSettings', () { test('openSettings は PermissionService.openSettings に委譲する', () async { final provider = _makeProvider(f); await provider.openSettings(); expect(f.permission.openSettingsCallCount, 1); }); }); group('CameraProvider.capturePreviewSnapshot', () { test('最新フレームがない場合,null を返す', () async { final provider = _makeProvider(f); final result = await provider.capturePreviewSnapshot(); expect(result, isNull); }); }); group('CameraProvider.disposeCamera', () { test('disposeCamera 後に isInitialized が false である', () async { final provider = _makeProvider(f); await provider.disposeCamera(); expect(provider.isInitialized, isFalse); }); test('disposeCamera はリスナーへ通知する', () async { 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 { f.permission.cameraGranted = false; final provider = _makeProvider(f); await provider.initialize(); expect(provider.errorMessage, 'カメラの権限が必要です'); }); test('カメラ権限が拒否されると permissionDenied が true になる', () async { f.permission.cameraGranted = false; final provider = _makeProvider(f); await provider.initialize(); expect(provider.permissionDenied, isTrue); }); test('カメラ権限が拒否されると isInitialized は false のままである', () async { f.permission.cameraGranted = false; final provider = _makeProvider(f); await provider.initialize(); expect(provider.isInitialized, isFalse); }); test( 'initialize は SoundService.init と PreviewRegionService.load を呼ぶ', () async { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 完了後の最終値ではなく,保存完了通知時点の履歴で確認する. 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 の最終状態としてはこのメッセージが残る(現状の実装挙動). 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 { 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 { 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 { 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 { 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 を // 'カメラの権限が必要です' に上書きする.現状の実装挙動を固定する. 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, 'カメラの権限が必要です'); }); }); }