diff --git "a/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" "b/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" index b7bfc1b..872b4b8 100644 --- "a/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" +++ "b/docs/04_SPEC/SPEC_02_\343\202\253\343\203\241\343\203\251\346\222\256\345\275\261\344\273\225\346\247\230\346\233\270.md" @@ -127,7 +127,7 @@ ### ファイル命名規則 -撮影 1 回で 3 ファイルを生成する(baseName は共通.重複時 `_1`, `_2` ...): +撮影 1 回で 3 ファイルを生成する(baseName は共通.重複時 `_1`, `_2` ...): | 種別 | ファイル名 | 概要 | | --- | --- | --- | @@ -159,8 +159,8 @@ - 一覧画面の詳細表示オーバーレイから削除を実行する - 削除前に確認ダイアログを表示する - - `.preview.jpg` の場合:「この画像を削除しますか?(DNG ファイルとメタデータも一緒に削除されます)」 - - `.png`(旧通常モード)の場合:「この画像を削除しますか?」 + - `.preview.jpg` の場合: 「この画像を削除しますか?(DNG ファイルとメタデータも一緒に削除されます)」 + - `.png`(旧通常モード)の場合: 「この画像を削除しますか?」 - `.preview.jpg` 削除時は同 baseName の `.dng` と `.meta.json` も**連動削除**する - ファイルシステムから物理削除する(ゴミ箱機能なし) - 削除後,MediaStore からも当該エントリを除去する diff --git "a/docs/04_SPEC/SPEC_03_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\343\201\250\351\235\236\346\251\237\350\203\275.md" "b/docs/04_SPEC/SPEC_03_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\343\201\250\351\235\236\346\251\237\350\203\275.md" index 833d74f..f3ab590 100644 --- "a/docs/04_SPEC/SPEC_03_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\343\201\250\351\235\236\346\251\237\350\203\275.md" +++ "b/docs/04_SPEC/SPEC_03_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\343\201\250\351\235\236\346\251\237\350\203\275.md" @@ -55,7 +55,7 @@ ※ 撮影・画像変換・診断の実処理は Android ネイティブ `android/.../RawCapturePlugin.kt`(Camera2 / `DngCreator`)が担い, -`raw_capture_service.dart` が MethodChannel で橋渡しする。 +`raw_capture_service.dart` が MethodChannel で橋渡しする. ### レイヤー構成 diff --git "a/docs/04_SPEC/SPEC_04_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\345\220\221\343\201\215\343\201\250\343\202\257\343\203\255\343\203\203\343\203\227.md" "b/docs/04_SPEC/SPEC_04_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\345\220\221\343\201\215\343\201\250\343\202\257\343\203\255\343\203\203\343\203\227.md" index 52d0a66..af2bcdf 100644 --- "a/docs/04_SPEC/SPEC_04_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\345\220\221\343\201\215\343\201\250\343\202\257\343\203\255\343\203\203\343\203\227.md" +++ "b/docs/04_SPEC/SPEC_04_\343\203\227\343\203\254\343\203\223\343\203\245\343\203\274\345\220\221\343\201\215\343\201\250\343\202\257\343\203\255\343\203\203\343\203\227.md" @@ -7,12 +7,12 @@ - カメラ制御・撮影フロー・保存ファイルは [SPEC_02 カメラ撮影仕様書](SPEC_02_カメラ撮影仕様書.md) を参照 - 本書はプレビュー表示の向き・クロップについて SPEC_02「プレビュー表示」の記述を**詳細化・優先**する -> **重要:撮影画像(DNG)には影響しない.** 本書が扱う向き変換・クロップは +> **重要: 撮影画像(DNG)には影響しない.** 本書が扱う向き変換・クロップは > すべて**プレビュー表示のみ**の処理である.保存される DNG は別経路(Camera2)で取得する. --- -## 1. 背景:なぜ向き変換が必要か +## 1. 背景: なぜ向き変換が必要か スマホを**画面が下向き**になるようにアタッチメントへ載せて口元を撮影する運用のため, カメラの生プレビューをそのまま表示すると,撮影者から見て上下・左右が反転している. @@ -24,7 +24,7 @@ - 上部のアタッチメントラベル「SmTIAS 001」が**鏡像+上下反転**で写る(=向き変換が効いている証拠) - カラーチャートが画面右,位置合わせマーカーが四隅 -> **基準スクリーンショットについて:** 実機の基準画像は被写体(顔・口元)の +> **基準スクリーンショットについて:** 実機の基準画像は被写体(顔・口元)の > 個人情報を含むため Git にはコミットせず,`.gitignore` 除外の `local_reference/` > にローカル保管する.確認時はその画像と見比べる(§5). @@ -49,7 +49,7 @@ プレビューには **ライブ系(描画)** と **スナップショット系(保存中表示)** の2系統があり, **いずれも最終的に同じ `RegionCrop`(同じ `region`)でクロップ**して表示を一致させる. -``` +```text ┌─ ライブプレビュー系 (Flutter 描画) ───────────┐ │ AspectRatio(h/w で横→縦補正) │ 生カメラフレーム │ OrientedPreview(向き変換 = 180°回転) │ @@ -101,9 +101,9 @@ 向き変換・クロップに関わる変更を行ったら,**2段階**で確認する. -### 段階1:自動チェック(リファクタ前後で見た目が変わっていないか) +### 段階1: 自動チェック(リファクタ前後で見た目が変わっていないか) -``` +```bash flutter test test/widgets/preview_orientation_golden_test.dart ``` @@ -116,12 +116,12 @@ > その場合のみ `flutter test --update-goldens` で正解を更新し, > 更新後のゴールデン画像が意図通りかを目視確認してからコミットする. -### 段階2:実機での最終確認(向きが本当に正しいか) +### 段階2: 実機での最終確認(向きが本当に正しいか) - 実機で口元を撮影し,**画面全体のスクリーンショット**を撮る - ローカル保管の基準画像(`local_reference/` 配下,個人情報のため非コミット) と並べて目視で一致を確認する -- 確認観点: +- 確認観点: - 口元が中央に拡大表示されているか - 上部ラベル「SmTIAS 001」が鏡像+上下反転で写るか - カラーチャートが右,マーカーが四隅にあるか diff --git "a/docs/05_TECH/TECH_01_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\350\250\255\350\250\210\343\201\256\347\265\214\347\267\257\343\201\250\345\220\210\346\204\217.md" "b/docs/05_TECH/TECH_01_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\350\250\255\350\250\210\343\201\256\347\265\214\347\267\257\343\201\250\345\220\210\346\204\217.md" index b91ee8b..a86562e 100644 --- "a/docs/05_TECH/TECH_01_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\350\250\255\350\250\210\343\201\256\347\265\214\347\267\257\343\201\250\345\220\210\346\204\217.md" +++ "b/docs/05_TECH/TECH_01_\345\256\232\351\207\217\343\203\242\343\203\274\343\203\211\350\250\255\350\250\210\343\201\256\347\265\214\347\267\257\343\201\250\345\220\210\346\204\217.md" @@ -2,56 +2,56 @@ 本書は SmTIAS-LightSim 側と SmTIAS-Capture 側の**往復書簡(旧 TECH_01 要求仕様 / TECH_03 実装報告・相談 / TECH_04 回答)を統合・要約したアーカイブ**である.定量撮影モードがなぜ・どう決まったかの記録に絞る. -- **現行の確定仕様** → [SPEC_02 カメラ撮影仕様書](../04_SPEC/SPEC_02_カメラ撮影仕様書.md)(画面は [SPEC_01](../04_SPEC/SPEC_01_画面機能仕様書.md)、設計・非機能は [SPEC_03](../04_SPEC/SPEC_03_アーキテクチャと非機能.md)) +- **現行の確定仕様** → [SPEC_02 カメラ撮影仕様書](../04_SPEC/SPEC_02_カメラ撮影仕様書.md)(画面は [SPEC_01](../04_SPEC/SPEC_01_画面機能仕様書.md),設計・非機能は [SPEC_03](../04_SPEC/SPEC_03_アーキテクチャと非機能.md)) - **実装の経緯・設計判断・失敗パターン** → [PROGRESS.md](../PROGRESS.md) - **実機能力の生データ** → [camera_diagnostics_20260531_161734.json](../06_TEST/camera_diagnostics_20260531_161734.json) -> 原文の往復書簡には SmTIAS-LightSim リポジトリ側のファイル(検証履歴 TEST_01/03、評価仕様 SPEC_03/04 など。**本リポジトリの SPEC_03 とは別物**)への参照が含まれるが、それらは SmTIAS-LightSim 側に残り本リポジトリには存在しない. +> 原文の往復書簡には SmTIAS-LightSim リポジトリ側のファイル(検証履歴 TEST_01/03,評価仕様 SPEC_03/04 など.**本リポジトリの SPEC_03 とは別物**)への参照が含まれるが,それらは SmTIAS-LightSim 側に残り本リポジトリには存在しない. -## 背景:なぜ定量モードが必要だったか +## 背景: なぜ定量モードが必要だったか -SmTIAS-LightSim(Mitsuba 3 による光散乱シミュ)と実機撮影を定量比較する研究で、シミュ vs 実機の残差が縮まらなかった。原因は、SmTIAS-Capture の通常撮影(PNG)が **AE/AWB/NR/EDGE/TONEMAP すべて自動**で、同じシーンを撮っても値が揺らいでいたこと。つまり「比較対象の実機データ自体が定量的に不安定」だった。 +SmTIAS-LightSim(Mitsuba 3 による光散乱シミュ)と実機撮影を定量比較する研究で,シミュ vs 実機の残差が縮まらなかった.原因は,SmTIAS-Capture の通常撮影(PNG)が **AE/AWB/NR/EDGE/TONEMAP すべて自動**で,同じシーンを撮っても値が揺らいでいたこと.つまり「比較対象の実機データ自体が定量的に不安定」だった. -→ Camera2 API を**完全マニュアル制御**し、ISP 介入を排した **RAW(DNG)**で撮影するモードを追加して固定化する、という方針になった。 +→ Camera2 API を**完全マニュアル制御**し,ISP 介入を排した **RAW(DNG)**で撮影するモードを追加して固定化する,という方針になった. ## 実機能力(AQUOS sense3 / SH-02M フロントカメラ) -実機診断(`getCameraDiagnostics`)の結果、定量モードに必要な能力をすべて満たすことを確認: +実機診断(`getCameraDiagnostics`)の結果,定量モードに必要な能力をすべて満たすことを確認: | 項目 | 値 | |---|---| | ハードウェアレベル | **LEVEL_3**(最上位) | | 能力 | MANUAL_SENSOR / MANUAL_POST_PROCESSING / **RAW** 対応 | -| RAW 出力 | 3264×2448、BGGR、10-bit(white level 1023 / black level 64) | +| RAW 出力 | 3264×2448,BGGR,10-bit(white level 1023 / black level 64) | | 露光時間レンジ | 約 1/12530 s 〜 0.87 s | | ISO レンジ | 40〜2506 | -| AF | **OFF のみ**(固定焦点。近接ピントはアタッチメント光学系前提) | +| AF | **OFF のみ**(固定焦点.近接ピントはアタッチメント光学系前提) | | LSC マップサイズ | 13×17(実キャプチャ結果から判明) | -詳細は camera_diagnostics JSON を参照。 +詳細は camera_diagnostics JSON を参照. ## 確定した撮影設定(要点) -全パラメータは [SPEC_02 §撮影設定](../04_SPEC/SPEC_02_カメラ撮影仕様書.md) に記載。要点のみ: +全パラメータは [SPEC_02 §撮影設定](../04_SPEC/SPEC_02_カメラ撮影仕様書.md) に記載.要点のみ: -- `CONTROL_MODE=OFF` で AE/AWB/AF/NR/EDGE をすべて OFF、TONEMAP は線形 -- 露光 **1/120s**・ISO **40** 固定、`BLACK_LEVEL_LOCK=true` +- `CONTROL_MODE=OFF` で AE/AWB/AF/NR/EDGE をすべて OFF,TONEMAP は線形 +- 露光 **1/120s**・ISO **40** 固定,`BLACK_LEVEL_LOCK=true` - フォーマットは **RAW_SENSOR → `DngCreator` で DNG 出力** -- 設定反映を確実にするため **3 枚連続キャプチャして最初の 2 枚を破棄**、3 枚目を採用 -- 撮影モード切替トグル UI は採用せず、**定量モードを既定動作**とした(通常 PNG 経路 `captureFullResolutionPng` はコード温存) +- 設定反映を確実にするため **3 枚連続キャプチャして最初の 2 枚を破棄**,3 枚目を採用 +- 撮影モード切替トグル UI は採用せず,**定量モードを既定動作**とした(通常 PNG 経路 `captureFullResolutionPng` はコード温存) ## SHADING_MODE = HIGH_QUALITY の根拠(重要な合意) -定量モードの核心的な設計判断。観察と物理的整合性から **HIGH_QUALITY を本流で維持**することで合意した。 +定量モードの核心的な設計判断.観察と物理的整合性から **HIGH_QUALITY を本流で維持**することで合意した. -- 実装中の観察:`SHADING_MODE=OFF` だと LSC マップが**全要素 1.0**(Android 仕様)になり情報が取れない。`HIGH_QUALITY` だと ISP が LSC を適用し、**実ゲインマップ**が取得できる。 -- 物理的整合性:SmTIAS-LightSim の Mitsuba **orthographic 出力**は平行光線サンプリングで cos⁴ falloff を持たず、**「ISP が完璧に LSC 補正できた場合の理想値」相当**。これと **HIGH_QUALITY DNG** を直接比較するのが物理的に最も整合する(残差=「実機 LSC 補正の不完全度+シミュ物理モデル誤差」=まさに評価したい本質)。 -- 逆に `OFF`(生 Bayer)を使うとシミュ側に物理 cos⁴ モデル(絞り径・口径食など)を足す必要があり、**自由度が増えて overfitting リスク**が上がる。 -- 結論:**HIGH_QUALITY 維持。SmTIAS-Capture 側の実装変更は不要**。LSC マップは検証用に meta.json へ温存する。 +- 実装中の観察: `SHADING_MODE=OFF` だと LSC マップが**全要素 1.0**(Android 仕様)になり情報が取れない.`HIGH_QUALITY` だと ISP が LSC を適用し,**実ゲインマップ**が取得できる. +- 物理的整合性: SmTIAS-LightSim の Mitsuba **orthographic 出力**は平行光線サンプリングで cos⁴ falloff を持たず,**「ISP が完璧に LSC 補正できた場合の理想値」相当**.これと **HIGH_QUALITY DNG** を直接比較するのが物理的に最も整合する(残差=「実機 LSC 補正の不完全度+シミュ物理モデル誤差」=まさに評価したい本質). +- 逆に `OFF`(生 Bayer)を使うとシミュ側に物理 cos⁴ モデル(絞り径・口径食など)を足す必要があり,**自由度が増えて overfitting リスク**が上がる. +- 結論: **HIGH_QUALITY 維持.SmTIAS-Capture 側の実装変更は不要**.LSC マップは検証用に meta.json へ温存する. ### 将来 SHADING_OFF を追加検証する条件(フォールバック C1–C3) -次のいずれかが起きたら「同一シーンを HQ と OFF で 1 セット撮影」する追加実装を検討する(現時点では未到達・実装不要): +次のいずれかが起きたら「同一シーンを HQ と OFF で 1 セット撮影」する追加実装を検討する(現時点では未到達・実装不要): - **C1**: シミュ vs HQ DNG の残差が再現的に大きい(CoV/勾配残差 1.5× 以上 + 形が物理的に説明不能) - **C2**: LSC マップが撮影ごとに大きく変動(中央値の変動 > 5%)→ ISP が AWB 連動で動的計算している証拠 @@ -59,20 +59,20 @@ ## 露光・ISO の目安(合意値) -10-bit DNG(黒レベル 64 除去後)で: +10-bit DNG(黒レベル 64 除去後)で: - 白板中央の **mean = 400〜600**(中央値約 500) -- 飽和回避:**p99 < 900**(除去後 836) +- 飽和回避: **p99 < 900**(除去後 836) -→ 確定値 **1/120s + ISO 40** で mean G ≈ 461・飽和率 0% を達成(基準測定値は PROGRESS.md 参照)。 +→ 確定値 **1/120s + ISO 40** で mean G ≈ 461・飽和率 0% を達成(基準測定値は PROGRESS.md 参照). ## 作業フェーズ分け(合意) -「再現性より先に、そもそもシミュと対応が取れるかを 1 ショットで確認する」二段階方式: +「再現性より先に,そもそもシミュと対応が取れるかを 1 ショットで確認する」二段階方式: -- **フェーズ A(1 ショット対応検証)**: SmTIAS-Capture 側は ①画素統計を meta.json に追加 ②Hot pixel map 取得 ③白板 1 枚を試し撮りして adb pull 転送。SmTIAS 側が DNG/メタ読み込みを実装し 1 ショット突合 → 残差 2.1× がどう変化するか観察。**①②は実装済み**。 -- **フェーズ B(連射 V1 再現性)**: フェーズ A で対応の見通しが立った場合のみ着手。10 連射 UI(`*_burst{0..9}.dng`)+ 再現性検証(mean CoV < 1%)。 +- **フェーズ A(1 ショット対応検証)**: SmTIAS-Capture 側は ①画素統計を meta.json に追加 ②Hot pixel map 取得 ③白板 1 枚を試し撮りして adb pull 転送.SmTIAS 側が DNG/メタ読み込みを実装し 1 ショット突合 → 残差 2.1× がどう変化するか観察.**①②は実装済み**. +- **フェーズ B(連射 V1 再現性)**: フェーズ A で対応の見通しが立った場合のみ着手.10 連射 UI(`*_burst{0..9}.dng`)+ 再現性検証(mean CoV < 1%). ## データ転送 -**`adb pull` 推奨**。MTP は禁止しないが、16 MB 級の DNG でコピー直後にサイズが正しく見えない不整合が確認されているため非推奨。 +**`adb pull` 推奨**.MTP は禁止しないが,16 MB 級の DNG でコピー直後にサイズが正しく見えない不整合が確認されているため非推奨. diff --git a/lib/constants/storage_paths.dart b/lib/constants/storage_paths.dart new file mode 100644 index 0000000..a521ff8 --- /dev/null +++ b/lib/constants/storage_paths.dart @@ -0,0 +1,7 @@ +// 撮影データの保存先に関する共有定数. + +/// 撮影画像・メタデータの既定の保存先ディレクトリ. +/// +/// 共有ストレージの `Pictures/MiniTIAS`.`FileService` の保存先と +/// `GalleryProvider` の取得元の既定値として参照される. +const defaultCaptureBasePath = '/storage/emulated/0/Pictures/MiniTIAS'; diff --git a/lib/providers/gallery_provider.dart b/lib/providers/gallery_provider.dart index 215dd62..65b7ba4 100644 --- a/lib/providers/gallery_provider.dart +++ b/lib/providers/gallery_provider.dart @@ -2,16 +2,20 @@ import 'package:flutter/foundation.dart'; +import 'package:smtias_capture/constants/storage_paths.dart'; import 'package:smtias_capture/services/raw_capture_service.dart'; /// 画像一覧の取得・キャッシュ・削除操作を管理する. class GalleryProvider extends ChangeNotifier { - /// MediaStore 通知に使うサービスはコンストラクタで差し替え可能(省略時は既定実装). - GalleryProvider({RawCaptureService? rawCaptureService}) - : _rawCaptureService = rawCaptureService ?? RawCaptureService(); + /// 画像一覧の取得元ディレクトリは省略可能(省略時は本番の共有ストレージパス). + /// + /// MediaStore 通知に使うサービスもコンストラクタで差し替え可能(省略時は既定実装). + /// テストでは一時ディレクトリを渡して実メソッドを直接検証できる. + GalleryProvider({String? basePath, RawCaptureService? rawCaptureService}) + : _basePath = basePath ?? defaultCaptureBasePath, + _rawCaptureService = rawCaptureService ?? RawCaptureService(); - static const _basePath = '/storage/emulated/0/Pictures/MiniTIAS'; - + final String _basePath; final RawCaptureService _rawCaptureService; List _images = []; diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 911d013..c4f3ed9 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -2,15 +2,15 @@ import 'dart:io'; import 'dart:typed_data'; +import 'package:smtias_capture/constants/storage_paths.dart'; + /// 撮影画像の保存・命名・削除を行うサービス. class FileService { /// 保存先ディレクトリは省略可能(省略時は本番の共有ストレージパス). /// /// テストでは一時ディレクトリを渡して実メソッドを直接検証できる. FileService({String? directoryPath}) - : _directoryPath = directoryPath ?? _defaultBasePath; - - static const _defaultBasePath = '/storage/emulated/0/Pictures/MiniTIAS'; + : _directoryPath = directoryPath ?? defaultCaptureBasePath; final String _directoryPath; @@ -35,18 +35,6 @@ '${dt.minute.toString().padLeft(2, '0')}' '${dt.second.toString().padLeft(2, '0')}'; - /// PNG バイト列をファイルに書き込んで保存する. - /// - /// Android ネイティブ側で YUV→RGB→PNG 変換済みのバイト列を受け取り, - /// そのまま書き込むため高速に動作する. - Future saveImage(Uint8List pngBytes) async { - await _ensureDirectory(); - final fileName = await generateFileName(); - final filePath = '$directoryPath/$fileName'; - await File(filePath).writeAsBytes(pngBytes); - return filePath; - } - /// 候補名が衝突した場合に `_1`, `_2`, ... と連番サフィックスを付与し, /// 一意な名前を決定する(同期版). /// @@ -84,19 +72,6 @@ return '${baseName}_$suffix'; } - /// ファイル名を生成する. - /// - /// 形式: MiniTIAS_YYYYMMDD_HHmmss.png - /// 同秒の重複がある場合は _1, _2, ... と連番を付与する. - Future generateFileName() async { - final baseName = 'MiniTIAS_${_buildTimestamp(DateTime.now())}'; - final resolved = await _resolveUniqueNameAsync( - baseName, - (name) => File('$directoryPath/$name.png').exists(), - ); - return '$resolved.png'; - } - /// カメラ診断情報を JSON ファイルとして保存する. /// /// ファイル名: `camera_diagnostics_YYYYMMDD_HHmmss.json` @@ -115,19 +90,6 @@ return filePath; } - /// テスト用: タイムスタンプとファイル名一覧からファイル名を生成する. - static String generateFileNameSync( - String timestamp, - List existingFiles, - ) { - final baseName = 'MiniTIAS_$timestamp'; - final resolved = _resolveUniqueName( - baseName, - (name) => existingFiles.contains('$name.png'), - ); - return '$resolved.png'; - } - /// 定量モード撮影用のファイル名 prefix を生成する. /// /// 形式: `MiniTIAS_QM_YYYYMMDD_HHmmss` diff --git a/lib/services/raw_capture_service.dart b/lib/services/raw_capture_service.dart index 545858b..338c256 100644 --- a/lib/services/raw_capture_service.dart +++ b/lib/services/raw_capture_service.dart @@ -125,16 +125,6 @@ /// DNG ファイルのバイト数(書き込み確認用). final int dngFileSize; - /// 縮小プレビュー JPEG の絶対パス. - /// - /// Kotlin 側でプレビュー生成に失敗した場合は null. - final String? previewPath; - - /// プレビュー JPEG のバイト数. - /// - /// Kotlin 側でプレビュー生成に失敗した場合は null. - final int? previewFileSize; - /// 撮影メタデータ(設定値・実測値・センサ特性など). final Map metadata; @@ -150,8 +140,6 @@ const QuantitativeCaptureResult({ required this.dngPath, required this.dngFileSize, - this.previewPath, - this.previewFileSize, required this.metadata, this.lscMap, this.lscMapRowCount, @@ -163,9 +151,6 @@ final dngPath = map['dngPath'] as String; final dngFileSize = (map['dngFileSize'] as num).toInt(); - final previewPath = map['previewPath'] as String?; - final previewFileSize = (map['previewFileSize'] as num?)?.toInt(); - final rawMetadata = map['metadata']; final metadata = (_deepConvert(rawMetadata) as Map?) ?? {}; @@ -184,8 +169,6 @@ return QuantitativeCaptureResult( dngPath: dngPath, dngFileSize: dngFileSize, - previewPath: previewPath, - previewFileSize: previewFileSize, metadata: metadata, lscMap: lscMap, lscMapRowCount: lscMapRowCount, diff --git a/test/providers/camera_provider_test.dart b/test/providers/camera_provider_test.dart index 72a291c..d4fdbd2 100644 --- a/test/providers/camera_provider_test.dart +++ b/test/providers/camera_provider_test.dart @@ -252,45 +252,48 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); + // 各テストごとに新しいフェイク一式を生成する(setUp は各テスト前に実行される + // ため,従来の `final f = _Fakes();` と同じく独立したインスタンスが得られる). + // プロバイダ生成前のフェイク事前設定はテストごとに異なるため,プロバイダ生成 + // (_makeProvider)は各テスト内に残す. + late _Fakes f; + + setUp(() { + f = _Fakes(); + }); + 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); @@ -299,7 +302,6 @@ group('CameraProvider.takePicture ガード条件(未初期化)', () { test('未初期化のとき,takePicture は captureQuantitativeDng を呼ばない', () async { - final f = _Fakes(); final provider = _makeProvider(f); await provider.takePicture( @@ -310,7 +312,6 @@ }); test('未初期化のとき,takePicture は generateQuantitativeBaseName を呼ばない', () async { - final f = _Fakes(); final provider = _makeProvider(f); await provider.takePicture(); @@ -319,7 +320,6 @@ }); test('未初期化のとき,takePicture は requestStorage を呼ばない', () async { - final f = _Fakes(); final provider = _makeProvider(f); await provider.takePicture(); @@ -328,7 +328,6 @@ }); test('未初期化のとき,takePicture は何も保存せず isSaving は false のままである', () async { - final f = _Fakes(); final provider = _makeProvider(f); await provider.takePicture(); @@ -341,7 +340,6 @@ }); test('未初期化のとき,takePicture は lastSavedFileName を変更しない', () async { - final f = _Fakes(); final provider = _makeProvider(f); await provider.takePicture(); @@ -350,7 +348,6 @@ }); test('未初期化のとき,takePicture は errorMessage を設定しない', () async { - final f = _Fakes(); final provider = _makeProvider(f); await provider.takePicture(); @@ -359,7 +356,6 @@ }); test('未初期化のとき,takePicture はサービスを一切呼ばない(ログが空)', () async { - final f = _Fakes(); final provider = _makeProvider(f); await provider.takePicture(); @@ -370,7 +366,6 @@ group('CameraProvider.toggleSound', () { test('toggleSound は SoundService.toggleSound に委譲する', () async { - final f = _Fakes(); final provider = _makeProvider(f); await provider.toggleSound(); @@ -379,7 +374,6 @@ }); test('toggleSound 後に isSoundEnabled が反転する', () async { - final f = _Fakes(); f.sound.soundEnabled = true; final provider = _makeProvider(f); @@ -389,7 +383,6 @@ }); test('toggleSound はリスナーへ通知する', () async { - final f = _Fakes(); final provider = _makeProvider(f); var notified = 0; provider.addListener(() => notified++); @@ -402,7 +395,6 @@ group('CameraProvider.playShutterSound', () { test('playShutterSound は SoundService.playShutterSound に委譲する', () async { - final f = _Fakes(); final provider = _makeProvider(f); await provider.playShutterSound(); @@ -413,7 +405,6 @@ group('CameraProvider.openSettings', () { test('openSettings は PermissionService.openSettings に委譲する', () async { - final f = _Fakes(); final provider = _makeProvider(f); await provider.openSettings(); @@ -424,7 +415,6 @@ group('CameraProvider.capturePreviewSnapshot', () { test('最新フレームがない場合,null を返す', () async { - final f = _Fakes(); final provider = _makeProvider(f); final result = await provider.capturePreviewSnapshot(); @@ -435,7 +425,6 @@ group('CameraProvider.disposeCamera', () { test('disposeCamera 後に isInitialized が false である', () async { - final f = _Fakes(); final provider = _makeProvider(f); await provider.disposeCamera(); @@ -444,7 +433,6 @@ }); test('disposeCamera はリスナーへ通知する', () async { - final f = _Fakes(); final provider = _makeProvider(f); var notified = 0; provider.addListener(() => notified++); @@ -457,7 +445,6 @@ group('CameraProvider.initialize(権限拒否で early-return)', () { test('カメラ権限が拒否されると errorMessage に「カメラの権限が必要です」が入る', () async { - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); @@ -467,7 +454,6 @@ }); test('カメラ権限が拒否されると permissionDenied が true になる', () async { - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); @@ -477,7 +463,6 @@ }); test('カメラ権限が拒否されると isInitialized は false のままである', () async { - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); @@ -489,7 +474,6 @@ test( 'initialize は SoundService.init と PreviewRegionService.load を呼ぶ', () async { - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); @@ -503,7 +487,6 @@ test( 'initialize は PreviewRegionService が返した領域を previewRegion に反映する', () async { - final f = _Fakes(); f.permission.cameraGranted = false; f.previewRegion.regionToReturn = const PreviewRegion( left: 0.1, @@ -521,7 +504,6 @@ ); test('権限永久拒否のとき permissionPermanentlyDenied が true になる', () async { - final f = _Fakes(); f.permission.cameraGranted = false; f.permission.cameraPermanentlyDenied = true; final provider = _makeProvider(f); @@ -538,7 +520,6 @@ // 実カメラ(availableCameras())に到達させない. test('previewBytes あり: 保存処理が定義順で 1 回ずつ呼ばれる', () async { - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); provider.debugSetInitializedForTest(); @@ -576,7 +557,6 @@ test( 'previewBytes あり: scanFile が dng → meta → preview の順で 3 回呼ばれる', () async { - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); provider.debugSetInitializedForTest(); @@ -594,7 +574,6 @@ ); test('previewBytes あり: 各保存サービスがちょうど 1 回呼ばれる', () async { - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); provider.debugSetInitializedForTest(); @@ -613,7 +592,6 @@ test( 'previewBytes が null: savePreviewJpeg を呼ばず scanFile は 2 回のみ', () async { - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); provider.debugSetInitializedForTest(); @@ -629,7 +607,6 @@ ); test('正常系完了後: isSaving が false に戻る', () async { - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); provider.debugSetInitializedForTest(); @@ -646,7 +623,6 @@ // notifyListeners() するが,直後に末尾 initialize() が cameraGranted=false で // errorMessage を 'カメラの権限が必要です' に上書きする. // そのため await 完了後の最終値ではなく,保存完了通知時点の履歴で確認する. - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); provider.debugSetInitializedForTest(); @@ -666,7 +642,6 @@ // 特性化: 保存成功後も末尾 initialize() が requestCamera=false で // early-return し errorMessage を 'カメラの権限が必要です' に上書きする. // takePicture の最終状態としてはこのメッセージが残る(現状の実装挙動). - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); provider.debugSetInitializedForTest(); @@ -679,7 +654,6 @@ }); test('正常系完了後: lastSavedFileName が dngPath のファイル名末尾に一致する', () async { - final f = _Fakes(); f.permission.cameraGranted = false; f.raw.resultToReturn = const QuantitativeCaptureResult( dngPath: '/test/dir/MiniTIAS_QM_20260623_153000.dng', @@ -697,7 +671,6 @@ }); test('保存中に isSaving が true になる(リスナー観測)', () async { - final f = _Fakes(); f.permission.cameraGranted = false; final provider = _makeProvider(f); provider.debugSetInitializedForTest(); @@ -715,7 +688,6 @@ }); test('ストレージ権限拒否: errorMessage が「ストレージの権限が必要です」になる', () async { - final f = _Fakes(); f.permission.cameraGranted = false; f.permission.storageGranted = false; final provider = _makeProvider(f); @@ -733,7 +705,6 @@ test( 'キャプチャ例外: 撮影フローが「撮影に失敗しました」を設定し lastSavedFileName が null になる', () async { - final f = _Fakes(); f.permission.cameraGranted = false; f.raw.captureShouldThrow = true; final provider = _makeProvider(f); @@ -769,7 +740,6 @@ // 特性化: catch で '撮影に失敗しました' を設定後,takePicture 末尾の // initialize() が requestCamera=false で early-return し errorMessage を // 'カメラの権限が必要です' に上書きする.現状の実装挙動を固定する. - final f = _Fakes(); f.permission.cameraGranted = false; f.raw.captureShouldThrow = true; final provider = _makeProvider(f); diff --git a/test/providers/gallery_provider_test.dart b/test/providers/gallery_provider_test.dart index fa8d92a..cfa1e64 100644 --- a/test/providers/gallery_provider_test.dart +++ b/test/providers/gallery_provider_test.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:smtias_capture/providers/gallery_provider.dart'; @@ -20,105 +19,16 @@ } } -/// テスト専用の 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; + late GalleryProvider provider; setUp(() async { tempDir = await Directory.systemTemp.createTemp( 'smtias_capture_gallery_test_', ); - provider = _GalleryProviderTestable(tempDir.path); + provider = GalleryProvider(basePath: tempDir.path); }); tearDown(() async { @@ -208,7 +118,7 @@ test('ディレクトリが存在しない場合,空リストを返す', () async { final nonExistentPath = '${tempDir.path}/nonexistent'; - final providerNonExistent = _GalleryProviderTestable(nonExistentPath); + final providerNonExistent = GalleryProvider(basePath: nonExistentPath); await providerNonExistent.loadImages(); @@ -216,14 +126,17 @@ }); 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(); + // ソートキーは statSync().modified(更新日時).実スリープに頼らず + // 各ファイルの mtime を明示設定して決定的・即時に時間差を作る. + final olderFile = File('${tempDir.path}/MiniTIAS_20260101_120000.png'); + final newerFile = File( + '${tempDir.path}/MiniTIAS_QM_20260101_120001.preview.jpg', + ); + await olderFile.create(); + await newerFile.create(); + // NTFS 等の mtime 精度より十分大きい 1 秒差を明示的に与える + olderFile.setLastModifiedSync(DateTime(2026, 1, 1, 12, 0, 0)); + newerFile.setLastModifiedSync(DateTime(2026, 1, 1, 12, 0, 1)); await provider.loadImages(); @@ -238,13 +151,18 @@ group('GalleryProvider.deleteImage', () { late Directory tempDir; - late _GalleryProviderTestable provider; + late _FakeRawCaptureService fakeRawCapture; + late GalleryProvider provider; setUp(() async { tempDir = await Directory.systemTemp.createTemp( 'smtias_capture_del_test_', ); - provider = _GalleryProviderTestable(tempDir.path); + fakeRawCapture = _FakeRawCaptureService(); + provider = GalleryProvider( + basePath: tempDir.path, + rawCaptureService: fakeRawCapture, + ); }); tearDown(() async { diff --git a/test/services/file_service_test.dart b/test/services/file_service_test.dart index 00d5f5d..5daec76 100644 --- a/test/services/file_service_test.dart +++ b/test/services/file_service_test.dart @@ -7,161 +7,6 @@ import 'package:smtias_capture/services/file_service.dart'; void main() { - group('FileService.generateFileNameSync', () { - test('同秒のファイルが存在しない場合,サフィックスなしのファイル名を返す', () { - final result = FileService.generateFileNameSync('20260404_120000', []); - expect(result, 'MiniTIAS_20260404_120000.png'); - }); - - test('同秒のファイルが存在する場合,_1 サフィックスを付与する', () { - final result = FileService.generateFileNameSync('20260404_120000', [ - 'MiniTIAS_20260404_120000.png', - ]); - expect(result, 'MiniTIAS_20260404_120000_1.png'); - }); - - test('_1 も存在する場合,_2 サフィックスを付与する', () { - final result = FileService.generateFileNameSync('20260404_120000', [ - 'MiniTIAS_20260404_120000.png', - 'MiniTIAS_20260404_120000_1.png', - ]); - expect(result, 'MiniTIAS_20260404_120000_2.png'); - }); - - test('異なるタイムスタンプのファイルが存在しても影響しない', () { - final result = FileService.generateFileNameSync('20260404_120000', [ - 'MiniTIAS_20260404_120001.png', - ]); - expect(result, 'MiniTIAS_20260404_120000.png'); - }); - - // --- 追加テスト: ファイル命名規則の詳細検証 --- - - test('返り値が MiniTIAS_ プレフィックスで始まる', () { - final result = FileService.generateFileNameSync('20260404_120000', []); - expect(result, startsWith('MiniTIAS_')); - }); - - test('返り値が .png 拡張子で終わる', () { - final result = FileService.generateFileNameSync('20260404_120000', []); - expect(result, endsWith('.png')); - }); - - test('連番が連続して欠けている場合(_1 が欠落),_2 ではなく _1 を返す', () { - // 仕様: suffix=1 から順に探すため _1 が存在しなければ _1 を返す - final result = FileService.generateFileNameSync('20260404_120000', [ - 'MiniTIAS_20260404_120000.png', - 'MiniTIAS_20260404_120000_2.png', // _1 が存在しない状態 - ]); - expect(result, 'MiniTIAS_20260404_120000_1.png'); - }); - - test('連番が多数存在する場合,次の番号を付与する', () { - final existing = [ - 'MiniTIAS_20260404_120000.png', - 'MiniTIAS_20260404_120000_1.png', - 'MiniTIAS_20260404_120000_2.png', - 'MiniTIAS_20260404_120000_3.png', - 'MiniTIAS_20260404_120000_4.png', - ]; - final result = FileService.generateFileNameSync( - '20260404_120000', - existing, - ); - expect(result, 'MiniTIAS_20260404_120000_5.png'); - }); - - test('空のタイムスタンプでも形式を維持する', () { - final result = FileService.generateFileNameSync('', []); - expect(result, 'MiniTIAS_.png'); - }); - }); - - group('FileService.saveImage', () { - late Directory tempDir; - late FileService service; - - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('smtias_capture_test_'); - service = FileService(directoryPath: tempDir.path); - }); - - tearDown(() async { - if (await tempDir.exists()) { - await tempDir.delete(recursive: true); - } - }); - - test('PNG バイト列を渡すと,ファイルパスを返す', () async { - final pngBytes = Uint8List.fromList([0, 1, 2, 3]); - final path = await service.saveImage(pngBytes); - expect(path, isNotEmpty); - }); - - test('PNG バイト列を渡すと,.png 拡張子のファイルが生成される', () async { - final pngBytes = Uint8List.fromList([0, 1, 2, 3]); - final path = await service.saveImage(pngBytes); - expect(path, endsWith('.png')); - }); - - test('PNG バイト列を渡すと,ファイル名が MiniTIAS_ プレフィックスを持つ', () async { - final pngBytes = Uint8List.fromList([0, 1, 2, 3]); - final path = await service.saveImage(pngBytes); - final fileName = path.split('/').last; - expect(fileName, startsWith('MiniTIAS_')); - }); - - test('渡したバイト列がそのままファイルに書き込まれる', () async { - final pngBytes = Uint8List.fromList([137, 80, 78, 71, 13, 10, 26, 10]); - final path = await service.saveImage(pngBytes); - final written = await File(path).readAsBytes(); - expect(written, equals(pngBytes)); - }); - - test('空のバイト列でもファイルを生成できる', () async { - final pngBytes = Uint8List(0); - final path = await service.saveImage(pngBytes); - expect(await File(path).exists(), isTrue); - final written = await File(path).readAsBytes(); - expect(written.length, 0); - }); - - test('同秒に 2 回呼び出すと,2 つ目のファイル名に _1 サフィックスが付く', () async { - final pngBytes = Uint8List.fromList([0, 1, 2, 3]); - // 最初の saveImage でファイルを作成 - final path1 = await service.saveImage(pngBytes); - // 同秒内に再度呼び出す(ファイルが既に存在する状態を擬似的に作る) - // generateFileName はファイルシステムを参照するため,1 ファイル作成後に呼ぶ - final path2 = await service.saveImage(pngBytes); - - final fileName1 = path1.split('/').last; - final fileName2 = path2.split('/').last; - - // 2 つのファイル名は異なるはず - // (同秒の場合 _1 サフィックスが付く,異なる秒の場合も名前が異なる) - if (fileName1.replaceAll(RegExp(r'_\d+\.png$'), '.png') == - fileName2.replaceAll(RegExp(r'_\d+\.png$'), '.png')) { - // 同秒の場合: 2 つ目は _1 サフィックスを持つはず - expect(fileName2, contains('_1.png')); - } else { - // 異なる秒の場合: それぞれ独立したタイムスタンプ付きファイル名 - expect(fileName1, isNot(equals(fileName2))); - } - }); - - test('ディレクトリが存在しない場合でも自動作成してファイルを保存する', () async { - final nonExistentSubDir = Directory('${tempDir.path}/sub/dir'); - final serviceWithSubDir = FileService( - directoryPath: nonExistentSubDir.path, - ); - expect(await nonExistentSubDir.exists(), isFalse); - - final pngBytes = Uint8List.fromList([0, 1, 2, 3]); - final path = await serviceWithSubDir.saveImage(pngBytes); - expect(await File(path).exists(), isTrue); - }); - }); - group('FileService.directoryPath', () { test('保存先ディレクトリのパスを返す', () { final service = FileService(); @@ -250,85 +95,107 @@ }); group('FileService.generateQuantitativeBaseNameSync', () { - test('同秒のファイルが存在しない場合,サフィックスなしの baseName を返す', () { - final result = FileService.generateQuantitativeBaseNameSync( - '20260404_120000', - [], - ); - expect(result, 'MiniTIAS_QM_20260404_120000'); - }); + const timestamp = '20260404_120000'; + const baseName = 'MiniTIAS_QM_$timestamp'; - test('同秒の .dng が存在する場合,_1 サフィックスを付与する', () { - final result = FileService.generateQuantitativeBaseNameSync( - '20260404_120000', - ['MiniTIAS_QM_20260404_120000.dng'], - ); - expect(result, 'MiniTIAS_QM_20260404_120000_1'); - }); - - test('_1.dng も存在する場合,_2 サフィックスを付与する', () { - final result = FileService.generateQuantitativeBaseNameSync( - '20260404_120000', - [ - 'MiniTIAS_QM_20260404_120000.dng', - 'MiniTIAS_QM_20260404_120000_1.dng', + // 「タイムスタンプ+既存ファイル一覧 → baseName」の等価検証は, + // 入力(existing)と期待値だけが異なる同型ケースのためテーブル駆動でまとめる. + // 重複判定は `.dng` の存在チェックのみで行い,suffix=1 から順に空きを探す仕様. + final cases = <({String name, List existing, String expected})>[ + ( + name: '同秒のファイルが存在しない場合,サフィックスなしの baseName を返す', + existing: [], + expected: baseName, + ), + ( + name: '同秒の .dng が存在する場合,_1 サフィックスを付与する', + existing: ['$baseName.dng'], + expected: '${baseName}_1', + ), + ( + name: '_1.dng も存在する場合,_2 サフィックスを付与する', + existing: ['$baseName.dng', '${baseName}_1.dng'], + expected: '${baseName}_2', + ), + ( + name: '異なるタイムスタンプの .dng が存在しても影響しない', + existing: ['MiniTIAS_QM_20260404_120001.dng'], + expected: baseName, + ), + ( + // _1 が存在しないため,欠番の _1 を返す + name: '連番に欠番がある場合(_1.dng が欠落),_2 ではなく _1 を返す', + existing: ['$baseName.dng', '${baseName}_2.dng'], + expected: '${baseName}_1', + ), + ( + name: '連番が多数存在する場合(_1〜_5.dng),_6 を付与する', + existing: [ + '$baseName.dng', + '${baseName}_1.dng', + '${baseName}_2.dng', + '${baseName}_3.dng', + '${baseName}_4.dng', + '${baseName}_5.dng', ], - ); - expect(result, 'MiniTIAS_QM_20260404_120000_2'); - }); + expected: '${baseName}_6', + ), + ( + // 重複判定は .dng のみを見るため,.meta.json のみでは重複とみなさない + name: '候補 .meta.json のみが存在し .dng がない場合,サフィックスなしを返す', + existing: ['$baseName.meta.json'], + expected: baseName, + ), + ]; - test('異なるタイムスタンプの .dng が存在しても影響しない', () { - final result = FileService.generateQuantitativeBaseNameSync( - '20260404_120000', - ['MiniTIAS_QM_20260404_120001.dng'], - ); - expect(result, 'MiniTIAS_QM_20260404_120000'); - }); + for (final c in cases) { + test(c.name, () { + final result = FileService.generateQuantitativeBaseNameSync( + timestamp, + c.existing, + ); + expect(result, c.expected); + }); + } test('返り値が MiniTIAS_QM_ プレフィックスで始まる', () { final result = FileService.generateQuantitativeBaseNameSync( - '20260404_120000', + timestamp, [], ); expect(result, startsWith('MiniTIAS_QM_')); }); + }); - test('連番に欠番がある場合(_1.dng が欠落),_2 ではなく _1 を返す', () { - // 仕様: suffix=1 から順に探すため _1 が存在しなければ _1 を返す - final result = FileService.generateQuantitativeBaseNameSync( - '20260404_120000', - [ - 'MiniTIAS_QM_20260404_120000.dng', - 'MiniTIAS_QM_20260404_120000_2.dng', // _1 が存在しない状態 - ], + group('FileService.generateQuantitativeBaseName (async)', () { + late Directory tempDir; + late FileService service; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'smtias_capture_qm_async_test_', ); - expect(result, 'MiniTIAS_QM_20260404_120000_1'); + service = FileService(directoryPath: tempDir.path); }); - test('連番が多数存在する場合(_1〜_5.dng),_6 を付与する', () { - final existing = [ - 'MiniTIAS_QM_20260404_120000.dng', - 'MiniTIAS_QM_20260404_120000_1.dng', - 'MiniTIAS_QM_20260404_120000_2.dng', - 'MiniTIAS_QM_20260404_120000_3.dng', - 'MiniTIAS_QM_20260404_120000_4.dng', - 'MiniTIAS_QM_20260404_120000_5.dng', - ]; - final result = FileService.generateQuantitativeBaseNameSync( - '20260404_120000', - existing, - ); - expect(result, 'MiniTIAS_QM_20260404_120000_6'); + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } }); - test('候補 .meta.json のみが存在し .dng がない場合,サフィックスなしを返す', () { - // 重複判定は .dng の存在チェックのみであるため, - // .meta.json のみ存在しても重複とみなさない - final result = FileService.generateQuantitativeBaseNameSync( - '20260404_120000', - ['MiniTIAS_QM_20260404_120000.meta.json'], - ); - expect(result, 'MiniTIAS_QM_20260404_120000'); + test('生成した baseName の .dng が既存の場合,衝突しない名前を返す', () async { + // 実時刻に依存せず「既存 .dng と衝突しない名前を返す」契約を決定的に検証する. + // 同秒なら _1 サフィックス,秒跨ぎでも別タイムスタンプとなり,いずれにせよ + // 既存ファイルと異なる名前になる(本番の衝突回避の実挙動). + final base1 = await service.generateQuantitativeBaseName(); + await File('${tempDir.path}/$base1.dng').writeAsBytes([0]); + + final base2 = await service.generateQuantitativeBaseName(); + + expect(base2, startsWith('MiniTIAS_QM_')); + expect(base2, isNot(base1)); + expect(File('${tempDir.path}/$base2.dng').existsSync(), isFalse); }); }); diff --git a/test/services/raw_capture_service_test.dart b/test/services/raw_capture_service_test.dart index 3b0c729..f63ccbf 100644 --- a/test/services/raw_capture_service_test.dart +++ b/test/services/raw_capture_service_test.dart @@ -138,78 +138,5 @@ expect(row[1], closeTo(2.0, 0.0001)); expect(row[2], closeTo(3.14, 0.0001)); }); - - // --- previewPath / previewFileSize 関連テスト --- - - test('previewPath と previewFileSize が両方 non-null の場合,フィールドに値がセットされる', () { - final map = { - 'dngPath': '/test.dng', - 'dngFileSize': 16000000, - 'metadata': {}, - 'previewPath': '/storage/emulated/0/Pictures/MiniTIAS/test.preview.jpg', - 'previewFileSize': 204800, - }; - final result = QuantitativeCaptureResult.fromMap(map); - expect( - result.previewPath, - '/storage/emulated/0/Pictures/MiniTIAS/test.preview.jpg', - ); - expect(result.previewFileSize, 204800); - expect(result.previewFileSize, isA()); - }); - - test('previewPath が null の場合,previewPath は null になる', () { - final map = { - 'dngPath': '/test.dng', - 'dngFileSize': 16000000, - 'metadata': {}, - 'previewPath': null, - 'previewFileSize': null, - }; - final result = QuantitativeCaptureResult.fromMap(map); - expect(result.previewPath, isNull); - expect(result.previewFileSize, isNull); - }); - - test( - 'map に previewPath / previewFileSize キー自体が存在しない場合でも fromMap が成功し両フィールドが null になる', - () { - // 後方互換: プレビュー生成前の Kotlin が返す map にキーが存在しない場合 - final map = { - 'dngPath': '/test.dng', - 'dngFileSize': 16000000, - 'metadata': {}, - }; - final result = QuantitativeCaptureResult.fromMap(map); - expect(result.previewPath, isNull); - expect(result.previewFileSize, isNull); - }, - ); - - test('previewFileSize が int 直接でも num 経由でも正しく int に変換される', () { - // int 直接のケース - final mapInt = { - 'dngPath': '/test.dng', - 'dngFileSize': 100, - 'metadata': {}, - 'previewPath': '/test.preview.jpg', - 'previewFileSize': 204800, // Dart int - }; - final resultInt = QuantitativeCaptureResult.fromMap(mapInt); - expect(resultInt.previewFileSize, 204800); - expect(resultInt.previewFileSize, isA()); - - // num (double) 経由のケース - final mapDouble = { - 'dngPath': '/test.dng', - 'dngFileSize': 100, - 'metadata': {}, - 'previewPath': '/test.preview.jpg', - 'previewFileSize': 204800.0, // double として渡される場合 - }; - final resultDouble = QuantitativeCaptureResult.fromMap(mapDouble); - expect(resultDouble.previewFileSize, 204800); - expect(resultDouble.previewFileSize, isA()); - }); }); }