diff --git a/CLAUDE.md b/CLAUDE.md
index 2698151..f8a888a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@
## 開発進捗
-最新: PLAN_05 フェーズ 1 として層②テストの共通ハーネスを作り,保存失敗が成功扱いになる不具合を赤→緑で修正した
+最新: PLAN_05 フェーズ 2 として層②テストを校正・撮影・切断・UI 連携に広げ,報告台帳の 4 件を赤→緑で修正した
※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる).
## 必須ルール(コード実装時)
diff --git a/TIASshot.Tests/CameraBaseCalibrationIntegrationTests.cs b/TIASshot.Tests/CameraBaseCalibrationIntegrationTests.cs
new file mode 100644
index 0000000..a5ca7d3
--- /dev/null
+++ b/TIASshot.Tests/CameraBaseCalibrationIntegrationTests.cs
@@ -0,0 +1,368 @@
+#if SELFTEST
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using NUnit.Framework;
+using OpenCvSharp;
+
+namespace TIASshot.Tests {
+
+ ///
+ /// CameraBase の自動校正(ProcessCalibrationFrame → DetectChart → 固定判定 → ホワイトバランス → CalcTcc)と,
+ /// 校正後・校正中の切断の結合ロジックテスト(層②).
+ ///
+ /// 仕様: SPEC_01「自動キャリブレーション」「撮影」「データ保存(キャリブレーション保存ファイル)」・
+ /// testing.md「層②: CameraBase の校正」「CameraBase の切断・終了」
+ /// - プレビュー中に舌診チャートを検出すると自動でキャリブレーションを開始する
+ /// - キャリブレーション完了後に撮影ボタンが有効になる
+ /// - 算出した変換行列等は保存フォルダに保存される
+ /// - 校正の途中で失敗したときは,再検出できる状態へ戻る
+ /// - 切断時はチャートマスク(ネイティブ資源)を解放する(CLAUDE.md: Mat は確実に解放する)
+ ///
+ /// 失敗の注入:
+ /// - チャートの映っていない一様なフレーム(SelfTestChart.CreatePlainFrame)を流す
+ /// - 校正の収束処理(LogCalibrationConverged)で 1 回だけ例外を投げる派生偽カメラ
+ ///
+ /// 観測は SelfTestCamera のアクセサ(IsCalibrated・PreviewError・ChartMasksSnapshot・CalibrationFolder)・
+ /// Form1.SelfTest.cs のアクセサ(SelfTestShotsEnabled)・保存されたファイルで行う.
+ /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる.
+ ///
+ [TestFixture]
+ public class CameraBaseCalibrationIntegrationTests {
+
+ // 校正のプレビューで流すフレーム数の余裕(ハーネスの Calibrate と同じ考え方)
+ private const int ExtraFrames = 60;
+
+ // 舌診チャートの色票数(SPEC_01: 24 色票)
+ private const int PatchCount = 24;
+
+ // ================================================================================
+ // 校正の状態遷移(正常系)
+ // ================================================================================
+
+ ///
+ /// 舌診チャートを流すと校正が完了し,撮影ボタンが無効から有効に変わる.
+ ///
+ [Test, Category("Integration")]
+ public void 校正_チャートを流す_校正完了で撮影ボタンが無効から有効になる() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.CreateChartCamera();
+ var enabledBefore = h.Form.SelfTestShotsEnabled;
+ RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
+ // CalcTcc → EnableShots は UI スレッドへの Invoke のため,反映をメッセージ処理で待つ
+ h.PumpUntil(() => h.Form.SelfTestShotsEnabled || !camera.IsCalibrated, "撮影ボタン有効化の反映", TimeSpan.FromSeconds(3));
+ return new {
+ enabledBefore,
+ camera.IsCalibrated,
+ PreviewError = camera.PreviewError?.ToString(),
+ enabledAfter = h.Form.SelfTestShotsEnabled,
+ };
+ });
+
+ // Assert
+ Assert.That(obs.enabledBefore, Is.False, "校正前は撮影ボタンが無効であること");
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.PreviewError, Is.Null, "校正中に例外が出ないこと");
+ Assert.That(obs.IsCalibrated, Is.True, "チャートを流すと校正が完了すること");
+ Assert.That(obs.enabledAfter, Is.True, "校正完了後に撮影ボタンが有効になること");
+ }
+ }
+
+ ///
+ /// 校正が完了すると,SPEC_01「キャリブレーション保存ファイル」に挙げたファイルが校正の保存フォルダに作られる.
+ ///
+ [Test, Category("Integration")]
+ public void 校正_チャートを流す_キャリブレーション保存ファイルが保存される() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.CreateChartCamera();
+ h.Calibrate(camera);
+ var folder = camera.CalibrationFolder;
+ var channels = GetConversionChannels();
+ var expected = new List {
+ Config.GetString("File/TccImage"),
+ Config.GetString("File/TccRoisImage"),
+ Config.GetString("File/TccRgbValues"),
+ };
+ foreach (var ch in channels) {
+ expected.Add(WithChannel("File/TccSrgbValues", ch));
+ expected.Add(WithChannel("File/ConvRgb2Srgb", ch));
+ expected.Add(WithChannel("File/ConvRgb2Xyz", ch));
+ expected.Add(WithChannel("File/ConvSrgb2Xyz", ch));
+ }
+ var saved = !string.IsNullOrEmpty(folder) && Directory.Exists(folder)
+ ? Directory.GetFiles(folder).Select(Path.GetFileName).ToList()
+ : new List();
+ return new { folder, channels, expected, saved, underRoot = folder != null && IsUnder(folder, h.SaveRoot) };
+ });
+
+ // Assert
+ Assert.That(obs.channels, Is.Not.Empty, "前提: 変換次元が 1 つ以上設定されていること");
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.underRoot, Is.True, $"校正の保存先が一時保存先の配下であること: {obs.folder}");
+ Assert.That(obs.saved, Is.SupersetOf(obs.expected), $"保存されたファイル: [{string.Join(", ", obs.saved)}]");
+ }
+ }
+
+ // ================================================================================
+ // 校正の状態遷移(失敗注入)
+ // ================================================================================
+
+ ///
+ /// チャートの映っていないフレームだけを流すと,校正は完了せず,撮影ボタンは無効のままである.
+ ///
+ [Test, Category("Integration")]
+ public void 校正_チャートの映っていないフレーム_校正が完了せず撮影ボタンが無効のまま() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.CreateCamera(i => SelfTestChart.CreatePlainFrame(i, SelfTestChart.FrameWidth, SelfTestChart.FrameHeight));
+ RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
+ // 誤って有効化の Invoke が積まれていた場合に反映されるよう,少しメッセージを処理してから観測する
+ h.PumpFor(TimeSpan.FromMilliseconds(100));
+ return new {
+ camera.FramesDelivered,
+ camera.IsCalibrated,
+ PreviewError = camera.PreviewError?.ToString(),
+ Masks = camera.ChartMasksSnapshot.Count,
+ enabled = h.Form.SelfTestShotsEnabled,
+ };
+ });
+
+ // Assert
+ Assert.That(obs.FramesDelivered, Is.EqualTo(MaxCalibrationFramesForFastConfig()), "前提: 上限までフレームを流したこと");
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.PreviewError, Is.Null, "チャートが無くても例外にならないこと");
+ Assert.That(obs.IsCalibrated, Is.False, "チャートが無ければ校正は完了しないこと");
+ Assert.That(obs.Masks, Is.Zero, "チャートが無ければマスクは作られないこと");
+ Assert.That(obs.enabled, Is.False, "校正が完了していないので撮影ボタンは無効のままであること");
+ }
+ }
+
+ ///
+ /// 校正の途中(収束処理)で例外が起きた後,再びプレビューを流すとチャートを再検出して校正を完了でき,
+ /// 撮影ボタンが有効になる(途中で失敗したときに再検出できる状態へ戻る).
+ ///
+ [Test, Category("Integration")]
+ public void 校正_途中で例外_再びプレビューを流すと再検出して校正が完了する() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.Connect(h.Track(new ConvergeFailOnceCamera(h.Form, h.ChartFrameFactory())));
+ RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
+ var firstError = camera.PreviewError;
+ var calibratedAfterFailure = camera.IsCalibrated;
+ var enabledAfterFailure = h.Form.SelfTestShotsEnabled;
+
+ RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
+ h.PumpUntil(() => h.Form.SelfTestShotsEnabled || !camera.IsCalibrated, "撮影ボタン有効化の反映", TimeSpan.FromSeconds(3));
+ return new {
+ injectedThrown = ReferenceEquals(firstError, camera.Injected),
+ firstError = firstError?.ToString(),
+ calibratedAfterFailure,
+ enabledAfterFailure,
+ calibratedAfterRetry = camera.IsCalibrated,
+ enabledAfterRetry = h.Form.SelfTestShotsEnabled,
+ };
+ });
+
+ // Assert
+ Assert.That(obs.injectedThrown, Is.True, $"前提: 注入した例外で 1 回目の校正が中断されたこと(実際: {obs.firstError ?? "例外なし"})");
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.calibratedAfterFailure, Is.False, "途中で失敗した校正は完了扱いにならないこと");
+ Assert.That(obs.enabledAfterFailure, Is.False, "途中で失敗した校正では撮影ボタンが有効にならないこと");
+ Assert.That(obs.calibratedAfterRetry, Is.True, "再びプレビューを流すと校正が完了すること");
+ Assert.That(obs.enabledAfterRetry, Is.True, "再校正の完了で撮影ボタンが有効になること");
+ }
+ }
+
+ ///
+ /// 校正の途中で例外が起きた後にチャートを再検出したとき,失敗した校正で作られた旧チャートマスクは解放される
+ /// (差し替えの際に旧マスクを Dispose せずに捨てるとネイティブメモリがリークする).
+ ///
+ [Test, Category("Integration")]
+ public void 校正_途中で例外の後に再検出_旧チャートマスクが全て解放される() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.Connect(h.Track(new ConvergeFailOnceCamera(h.Form, h.ChartFrameFactory())));
+ RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
+ var firstError = camera.PreviewError;
+ var oldMasks = camera.ChartMasksSnapshot.ToList();
+
+ RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
+ var newMasks = camera.ChartMasksSnapshot.ToList();
+ return new {
+ injectedThrown = ReferenceEquals(firstError, camera.Injected),
+ oldCount = oldMasks.Count,
+ oldDisposed = oldMasks.Count(m => m.IsDisposed),
+ newCount = newMasks.Count,
+ newDisposed = newMasks.Count(m => m.IsDisposed),
+ replaced = oldMasks.All(o => !newMasks.Any(n => ReferenceEquals(n, o))),
+ calibrated = camera.IsCalibrated,
+ };
+ });
+
+ // Assert
+ Assert.That(obs.injectedThrown, Is.True, "前提: 注入した例外で 1 回目の校正が中断されたこと");
+ Assert.That(obs.oldCount, Is.EqualTo(PatchCount), "前提: 1 回目の検出で 24 色票ぶんのマスクが作られたこと");
+ Assert.That(obs.calibrated, Is.True, "前提: 2 回目のプレビューで再検出・校正が完了したこと");
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.replaced, Is.True, "再検出でマスクが新しいものに差し替わること");
+ Assert.That(obs.oldDisposed, Is.EqualTo(obs.oldCount), "差し替えられた旧マスクが全て解放されること");
+ Assert.That(obs.newCount, Is.EqualTo(PatchCount), "新しいマスクが 24 色票ぶんあること");
+ Assert.That(obs.newDisposed, Is.Zero, "使用中の新しいマスクは解放されていないこと");
+ }
+ }
+
+ // ================================================================================
+ // 切断
+ // ================================================================================
+
+ ///
+ /// 校正完了後に切断すると,チャートマスクが全て解放され,カメラの保持するマスクが空になる.
+ ///
+ [Test, Category("Integration")]
+ public void 切断_校正完了後_チャートマスクが全て解放され空になる() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.CreateChartCamera();
+ h.Calibrate(camera);
+ var masks = camera.ChartMasksSnapshot.ToList();
+ Exception error = null;
+ try {
+ camera.Disconnect();
+ } catch (Exception ex) {
+ error = ex;
+ }
+ return new {
+ count = masks.Count,
+ disposed = masks.Count(m => m.IsDisposed),
+ remaining = camera.ChartMasksSnapshot.Count,
+ error = error?.ToString(),
+ };
+ });
+
+ // Assert
+ Assert.That(obs.count, Is.EqualTo(PatchCount), "前提: 校正で 24 色票ぶんのマスクが作られたこと");
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.error, Is.Null, "切断が例外にならないこと");
+ Assert.That(obs.disposed, Is.EqualTo(obs.count), "切断でマスクが全て解放されること");
+ Assert.That(obs.remaining, Is.Zero, "切断後はマスクを保持しないこと");
+ }
+ }
+
+ ///
+ /// 校正の途中(チャート検出後・変換行列算出前)にプレビューを止めて切断しても例外にならず,
+ /// チャートマスクが全て解放され,校正完了扱い・撮影ボタン有効化にはならない.
+ /// 校正フレーム数を大きくして,校正中の状態(IsCalibrating)を確実に作ってから切断する.
+ ///
+ [Test, Category("Integration")]
+ public void 切断_校正中_例外にならずチャートマスクが解放され撮影ボタンは無効のまま() {
+ // Arrange
+ var options = new IntegrationOptions().WithConfig("Calib/Frames", "100000");
+
+ // Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.CreateChartCamera();
+ camera.StartPreview(1000000, 1, false);
+ h.PumpUntil(() => camera.IsCalibrating || !camera.IsPreviewRunning, "チャート検出(校正の開始)", TimeSpan.FromSeconds(5));
+ var calibratingAtStop = camera.IsCalibrating;
+ camera.RequestStopPreview();
+ h.PumpUntil(() => !camera.IsPreviewRunning, "プレビューの停止", TimeSpan.FromSeconds(3));
+ var masks = camera.ChartMasksSnapshot.ToList();
+ Exception error = null;
+ try {
+ camera.Disconnect();
+ } catch (Exception ex) {
+ error = ex;
+ }
+ h.PumpFor(TimeSpan.FromMilliseconds(100));
+ return new {
+ calibratingAtStop,
+ PreviewError = camera.PreviewError?.ToString(),
+ count = masks.Count,
+ disposed = masks.Count(m => m.IsDisposed),
+ remaining = camera.ChartMasksSnapshot.Count,
+ error = error?.ToString(),
+ camera.IsCalibrated,
+ enabled = h.Form.SelfTestShotsEnabled,
+ };
+ }, options);
+
+ // Assert
+ Assert.That(obs.calibratingAtStop, Is.True, "前提: 校正中(チャート検出後)に停止したこと");
+ Assert.That(obs.count, Is.EqualTo(PatchCount), "前提: 24 色票ぶんのマスクがあること");
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.PreviewError, Is.Null, "校正中に停止してもプレビューで例外が出ないこと");
+ Assert.That(obs.error, Is.Null, "校正中の切断が例外にならないこと");
+ Assert.That(obs.disposed, Is.EqualTo(obs.count), "切断でマスクが全て解放されること");
+ Assert.That(obs.remaining, Is.Zero, "切断後はマスクを保持しないこと");
+ Assert.That(obs.IsCalibrated, Is.False, "校正の途中で切断したら校正完了扱いにならないこと");
+ Assert.That(obs.enabled, Is.False, "校正の途中で切断したら撮影ボタンは無効のままであること");
+ }
+ }
+
+ // ================================================================================
+ // 共通処理
+ // ================================================================================
+
+ /// プレビューを流し,停止するまでメッセージを処理して待つ(PreviewError は判定しない)
+ private static void RunPreview(IntegrationHarness h, SelfTestCamera camera, int maxFrames, bool stopWhenCalibrated) {
+ camera.StartPreview(maxFrames, 0, stopWhenCalibrated);
+ h.PumpUntil(() => !camera.IsPreviewRunning, "プレビューの終了");
+ }
+
+ /// 校正に必要なフレーム数+余裕(一時設定が読み込まれている STA スレッドで呼ぶ)
+ private static int MaxCalibrationFrames() {
+ return Config.GetInt("Calib/ChartSetCount") + Config.GetInt("Calib/Frames") + ExtraFrames;
+ }
+
+ /// ハーネスの既定設定(FastConfig)での MaxCalibrationFrames(テストスレッドで期待値に使う)
+ private static int MaxCalibrationFramesForFastConfig() {
+ return int.Parse(IntegrationHarness.FastConfig["Calib/ChartSetCount"])
+ + int.Parse(IntegrationHarness.FastConfig["Calib/Frames"]) + ExtraFrames;
+ }
+
+ private static string WithChannel(string key, int channel) {
+ return Config.GetString(key).Replace("{CN}", $"{channel:00}");
+ }
+
+ /// 設定の変換次元(Calib/ConversionChannels.SPEC_02: カンマ区切り・4〜17 の範囲のみ有効)
+ private static List GetConversionChannels() {
+ return Config.GetString("Calib/ConversionChannels")
+ .Split(',')
+ .Select(x => int.Parse(x.Trim()))
+ .Where(x => x >= 4 && x <= 17)
+ .Distinct()
+ .OrderBy(x => x)
+ .ToList();
+ }
+
+ private static bool IsUnder(string path, string root) {
+ var p = Path.GetFullPath(path).TrimEnd('\\') + "\\";
+ var r = Path.GetFullPath(root).TrimEnd('\\') + "\\";
+ return p.StartsWith(r, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// 校正の収束処理(LogCalibrationConverged.CalcTcc の直前に呼ばれる)で 1 回目だけ例外を投げる偽カメラ(失敗注入用).
+ /// 例外はプレビュースレッドで捕捉され PreviewError に記録される(プレビューは止まる).
+ ///
+ private sealed class ConvergeFailOnceCamera : SelfTestCamera {
+
+ private int _calls;
+
+ public ConvergeFailOnceCamera(Form1 form, Func frameFactory) : base(form, frameFactory) { }
+
+ /// 注入した例外(PreviewError と同一インスタンスかで注入の成立を確かめる)
+ public InvalidOperationException Injected { get; } = new InvalidOperationException("注入: 校正の収束処理で失敗");
+
+ protected override void LogCalibrationConverged() {
+ if (Interlocked.Increment(ref _calls) == 1) throw Injected;
+ base.LogCalibrationConverged();
+ }
+ }
+ }
+}
+#endif
diff --git a/TIASshot.Tests/CameraBaseShotIntegrationTests.cs b/TIASshot.Tests/CameraBaseShotIntegrationTests.cs
new file mode 100644
index 0000000..ade0e20
--- /dev/null
+++ b/TIASshot.Tests/CameraBaseShotIntegrationTests.cs
@@ -0,0 +1,505 @@
+#if SELFTEST
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using OpenCvSharp;
+
+namespace TIASshot.Tests {
+
+ ///
+ /// CameraBase の撮影ループ(ShotMulti → RunShotLoop → CaptureShots)・保存キューの後始末(ResetShotQueue・DrainShots)・
+ /// 保存中の切断の結合ロジックテスト(層②).保存失敗の通知は CameraBaseSaveIntegrationTests(フェーズ 1)で扱う.
+ ///
+ /// 仕様: SPEC_01「撮影」「撮影失敗時の挙動」「データ保存(撮影保存ファイル)」・testing.md「層②」
+ /// - 連続撮影は指定した枚数・間隔で撮影し,`Shot{NO}.png`・`sRGB{CN}_{NO}.jpg`・`Info.csv` を保存する
+ /// - フレーム取得に失敗した場合,その時点で撮影全体を中止し(残り枚数は撮影しない),中止した旨を通知する.
+ /// 取得済みの画像は保存し,`Info.csv` に中止した旨と理由を記録する.失敗した 1 枚は成功として記録されない
+ /// - `Info.csv` の撮影ログ部の列は `Shot,Time(ms),Note`.3 列目は中止した行にのみ理由が入る
+ /// (CSV として 3 列の構造が保たれること.理由にカンマ・改行・引用符が含まれても列・行が崩れない=RFC 4180)
+ /// - 撮影ボタンは保存が完了するまで無効のままとなり,保存処理の終了後に再び操作可能になる
+ /// - 撮影・保存で使った Mat(ネイティブ資源)は残さず解放する(CLAUDE.md・error-handling.md)
+ ///
+ /// 方針未決のためアサートしない: 枚数 0 の完了表示の有無,DrainShots の待機上限,SaveThread の同期 Invoke.
+ ///
+ /// 失敗の注入: CaptureFrame を上書きした派生偽カメラ()で,指定枚目に
+ /// CaptureFailedException を投げる/撮影開始時に保存先へ同名フォルダを作り画像保存を失敗させる.
+ /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる.
+ ///
+ [TestFixture]
+ public class CameraBaseShotIntegrationTests {
+
+ // 撮影ログのヘッダ(SPEC_01: Shot,Time(ms),Note)
+ private static readonly string[] ShotLogHeader = { "Shot", "Time(ms)", "Note" };
+
+ // 注入する撮影失敗の理由(カンマ・CRLF・ダブルクォートを含む.CSV の列・行を崩しうる文字)
+ private const string TrickyReason = "a,b\r\nc\"d";
+
+ // ================================================================================
+ // 撮影ループ(正常系)
+ // ================================================================================
+
+ ///
+ /// 連続撮影(3 枚・間隔 0)で,3 枚ぶんの撮影画像・sRGB 変換画像と Info.csv が保存され,
+ /// 撮影ログに 1〜3 枚目の行があり,撮影ボタンが再び有効になる.
+ ///
+ [Test, Category("Integration")]
+ public void ShotMulti_3枚間隔0_3枚ぶんのファイルと撮影ログが保存され撮影ボタンが再有効化される() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory())));
+ h.Calibrate(camera);
+ h.Form.SelfTestSetMultiShots("3", "0");
+ return ShootMultiAndObserve(h, camera);
+ }, ShotOptions());
+
+ // Assert
+ var expected = new List { obs.InfoName };
+ for (var no = 1; no <= 3; no++) expected.AddRange(obs.ExpectedFilesFor(no));
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.SavedFiles, Is.SupersetOf(expected), $"保存されたファイル: [{string.Join(", ", obs.SavedFiles)}]");
+ Assert.That(obs.ShotLogRows.Select(r => r[0]), Is.EqualTo(new[] { "1", "2", "3" }), "撮影ログに 1〜3 枚目の行があること");
+ Assert.That(obs.Errors, Is.Empty, "エラー通知が無いこと");
+ Assert.That(obs.ShotsEnabled, Is.True, "保存完了後に撮影ボタンが再び有効になること");
+ }
+ }
+
+ ///
+ /// 連続撮影(2 枚・間隔 150ms)で,撮影ログの 2 枚目の時刻が 1 枚目から間隔以上あとになる
+ /// (SPEC_01: 指定した間隔で連続撮影する).
+ ///
+ [Test, Category("Integration")]
+ public void ShotMulti_2枚間隔150ms_撮影ログの時刻が間隔以上離れる() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory())));
+ h.Calibrate(camera);
+ h.Form.SelfTestSetMultiShots("2", "150");
+ return ShootMultiAndObserve(h, camera);
+ }, ShotOptions());
+
+ // Assert
+ Assert.That(obs.ShotLogRows.Count, Is.EqualTo(2), "前提: 撮影ログが 2 行あること");
+ var t1 = long.Parse(obs.ShotLogRows[0][1]);
+ var t2 = long.Parse(obs.ShotLogRows[1][1]);
+ Assert.That(t2 - t1, Is.GreaterThanOrEqualTo(150), $"2 枚目は 1 枚目から 150ms 以上あとに撮影されること(1 枚目 {t1}ms,2 枚目 {t2}ms)");
+ }
+
+ ///
+ /// 連続撮影の枚数が 0 のとき,例外にならず,撮影ボタンが再び有効になる
+ /// (枚数 0 の完了表示の有無は仕様未決のためアサートしない).
+ ///
+ [Test, Category("Integration")]
+ public void ShotMulti_枚数0_例外にならず撮影ボタンが再有効化される() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory())));
+ h.Calibrate(camera);
+ h.Form.SelfTestSetMultiShots("0", "0");
+ return ShootMultiAndObserve(h, camera);
+ }, ShotOptions());
+
+ // Assert
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.ShotError, Is.Null, "枚数 0 の撮影が例外にならないこと");
+ Assert.That(obs.ShotsEnabled, Is.True, "枚数 0 の撮影の後も撮影ボタンが再び有効になること");
+ }
+ }
+
+ // ================================================================================
+ // 撮影ループ(失敗注入: フレーム取得の失敗)
+ // ================================================================================
+
+ ///
+ /// 連続撮影(3 枚)の 2 枚目でフレーム取得に失敗したとき,撮影を中止して 3 枚目は撮影せず,
+ /// 取得済みの 1 枚目だけが保存され,中止が理由つきで通知され,撮影ボタンが再び有効になる.
+ ///
+ [Test, Category("Integration")]
+ public void ShotMulti_2枚目のフレーム取得失敗_中止して取得済みのみ保存し理由を通知し撮影ボタンを再有効化する() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory()) { FailCaptureAt = 2, FailMessage = "注入: フレーム取得失敗" }));
+ h.Calibrate(camera);
+ h.Form.SelfTestSetMultiShots("3", "0");
+ return ShootMultiAndObserve(h, camera);
+ }, ShotOptions());
+
+ // Assert
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.ShotError, Is.Null, "フレーム取得の失敗は撮影の例外として呼び出し元へ漏れないこと(中止として扱う)");
+ Assert.That(obs.CaptureCalls, Is.EqualTo(2), "失敗した 2 枚目で中止し,3 枚目は撮影しないこと");
+ Assert.That(obs.SavedFiles, Is.SupersetOf(obs.ExpectedFilesFor(1)), "取得済みの 1 枚目は保存されること");
+ Assert.That(obs.SavedFiles, Has.No.Member(obs.RgbFileFor(2)), "失敗した 2 枚目は保存されないこと");
+ Assert.That(obs.SavedFiles, Has.No.Member(obs.RgbFileFor(3)), "中止後の 3 枚目は保存されないこと");
+ Assert.That(obs.Errors, Has.Some.Matches(e => e != null && e.Contains("注入: フレーム取得失敗")),
+ $"中止した旨が理由つきで通知されること.通知: [{string.Join(" / ", obs.Errors)}]");
+ Assert.That(obs.ShotsEnabled, Is.True, "中止後も撮影ボタンが再び有効になること");
+ }
+ }
+
+ ///
+ /// フレーム取得失敗の理由にカンマ・改行・ダブルクォートが含まれても,Info.csv の撮影ログは CSV(RFC 4180)として
+ /// Shot,Time(ms),Note の 3 列構造を保ち,中止行の Note は理由を含む 1 つのフィールドになる.失敗した枚は成功行として記録されない.
+ ///
+ [Test, Category("Integration")]
+ public void ShotMulti_理由にカンマ改行引用符を含むフレーム取得失敗_InfoCsvの中止行がNoteを含む3列になる() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory()) { FailCaptureAt = 2, FailMessage = TrickyReason }));
+ h.Calibrate(camera);
+ h.Form.SelfTestSetMultiShots("3", "0");
+ return ShootMultiAndObserve(h, camera);
+ }, ShotOptions());
+
+ // Assert
+ Assert.That(obs.InfoText, Is.Not.Null, "前提: Info.csv が保存されていること");
+ Assert.That(obs.ShotLogHeaderFound, Is.True, "前提: 撮影ログのヘッダ(Shot,Time(ms),Note)が 1 つの行として解釈できること");
+ var rows = obs.ShotLogRows;
+ Assert.That(rows.Count, Is.EqualTo(2),
+ $"撮影ログは 1 枚目(成功)と 2 枚目(中止)の 2 行であること.解釈結果: [{string.Join(" | ", rows.Select(r => string.Join(" ¦ ", r)))}]");
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(rows[0].Count, Is.EqualTo(2), "成功した 1 枚目の行は Shot,Time の 2 フィールドであること");
+ Assert.That(rows[0][0], Is.EqualTo("1"));
+ Assert.That(rows[0][1], Does.Match(@"^\d+$"), "1 枚目の Time は整数(ms)であること");
+ Assert.That(obs.RawShotLines, Has.Some.Matches(l => Regex.IsMatch(l, @"^1,\d+$")), "カンマを含まない 1 枚目の行は従来どおり 1,