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<String> 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<bool> requestCamera() async {
requestCameraCallCount++;
_log.add('requestCamera');
return cameraGranted;
}
@override
Future<bool> requestStorage() async {
requestStorageCallCount++;
_log.add('requestStorage');
return storageGranted;
}
@override
Future<bool> isCameraPermanentlyDenied() async => cameraPermanentlyDenied;
@override
Future<void> 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<String> generateQuantitativeBaseName() async {
generateBaseNameCallCount++;
_log.add('generateQuantitativeBaseName');
return baseNameToReturn;
}
@override
Future<String> saveQuantitativeMetadata(
String baseName,
Map<String, dynamic> metadata, {
List<List<double>>? lscMap,
int? lscMapRowCount,
int? lscMapColumnCount,
}) async {
saveMetadataCallCount++;
_log.add('saveQuantitativeMetadata');
return metaPathToReturn;
}
@override
Future<String> 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<String> scannedPaths = [];
@override
Future<QuantitativeCaptureResult> captureQuantitativeDng({
required String baseName,
required String directoryPath,
}) async {
captureCallCount++;
_log.add('captureQuantitativeDng');
if (captureShouldThrow) {
throw Exception('テスト用キャプチャ失敗');
}
return resultToReturn;
}
@override
Future<void> 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<void> init() async {
initCallCount++;
_log.add('soundInit');
}
@override
Future<void> toggleSound() async {
toggleCallCount++;
soundEnabled = !soundEnabled;
_log.add('toggleSound');
}
@override
Future<void> playShutterSound() async {
shutterCallCount++;
_log.add('playShutterSound');
}
@override
Future<void> 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<PreviewRegion> 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);
});
test('権限拒否が永久でないとき permissionPermanentlyDenied は false である', () async {
f.permission.cameraGranted = false;
f.permission.cameraPermanentlyDenied = false;
final provider = _makeProvider(f);
await provider.initialize();
expect(provider.permissionDenied, isTrue);
expect(provider.permissionPermanentlyDenied, isFalse);
});
});
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 = <String?>[];
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 = <bool>[];
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 = <String?>[];
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, 'カメラの権限が必要です');
});
});
}