Newer
Older
TIASShot / TIASshot.Tests / CameraBaseShotIntegrationTests.cs
#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 {

    /// <summary>
    /// 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 を上書きした派生偽カメラ(<see cref="ProbeCamera"/>)で,指定枚目に
    /// CaptureFailedException を投げる/撮影開始時に保存先へ同名フォルダを作り画像保存を失敗させる.
    /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる.
    /// </summary>
    [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";

        // ================================================================================
        // 撮影ループ(正常系)
        // ================================================================================

        /// <summary>
        /// 連続撮影(3 枚・間隔 0)で,3 枚ぶんの撮影画像・sRGB 変換画像と Info.csv が保存され,
        /// 撮影ログに 1〜3 枚目の行があり,撮影ボタンが再び有効になる.
        /// </summary>
        [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<string> { 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, "保存完了後に撮影ボタンが再び有効になること");
            }
        }

        /// <summary>
        /// 連続撮影(2 枚・間隔 150ms)で,撮影ログの 2 枚目の時刻が 1 枚目から間隔以上あとになる
        /// (SPEC_01: 指定した間隔で連続撮影する).
        /// </summary>
        [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)");
        }

        /// <summary>
        /// 連続撮影の枚数が 0 のとき,例外にならず,撮影ボタンが再び有効になる
        /// (枚数 0 の完了表示の有無は仕様未決のためアサートしない).
        /// </summary>
        [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 の撮影の後も撮影ボタンが再び有効になること");
            }
        }

        // ================================================================================
        // 撮影ループ(失敗注入: フレーム取得の失敗)
        // ================================================================================

        /// <summary>
        /// 連続撮影(3 枚)の 2 枚目でフレーム取得に失敗したとき,撮影を中止して 3 枚目は撮影せず,
        /// 取得済みの 1 枚目だけが保存され,中止が理由つきで通知され,撮影ボタンが再び有効になる.
        /// </summary>
        [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<string>(e => e != null && e.Contains("注入: フレーム取得失敗")),
                    $"中止した旨が理由つきで通知されること.通知: [{string.Join(" / ", obs.Errors)}]");
                Assert.That(obs.ShotsEnabled, Is.True, "中止後も撮影ボタンが再び有効になること");
            }
        }

        /// <summary>
        /// フレーム取得失敗の理由にカンマ・改行・ダブルクォートが含まれても,Info.csv の撮影ログは CSV(RFC 4180)として
        /// Shot,Time(ms),Note の 3 列構造を保ち,中止行の Note は理由を含む 1 つのフィールドになる.失敗した枚は成功行として記録されない.
        /// </summary>
        [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<string>(l => Regex.IsMatch(l, @"^1,\d+$")), "カンマを含まない 1 枚目の行は従来どおり 1,<time> の形であること");
                Assert.That(rows[1].Count, Is.EqualTo(3), "中止した 2 枚目の行は Shot,Time,Note の 3 フィールドであること");
                Assert.That(rows[1][0], Is.EqualTo("2"));
                Assert.That(rows[1][1], Does.Match(@"^\d+$"), "2 枚目の Time は整数(ms)であること");
                Assert.That(rows[1].Count > 2 ? rows[1][2] : "", Does.Contain(TrickyReason), "Note に元の理由がそのまま 1 フィールドとして入ること");
            }
        }

        // ================================================================================
        // 保存キューの後始末(SaveThread 例外時・ResetShotQueue)
        // ================================================================================

        /// <summary>
        /// 保存キューを作り直す(ResetShotQueue)とき,前のキューに残っていた Mat は解放される
        /// (BlockingCollection の破棄だけでは中身の Mat はリークする).
        /// </summary>
        [Test, Category("Integration")]
        public void ResetShotQueue_前のキューにMatが残っている_残っていたMatが全て解放される() {
            // Arrange / Act
            var obs = IntegrationHarness.Run(h => {
                var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory())));
                var left1 = new Mat(4, 4, MatType.CV_8UC3, Scalar.All(1));
                var left2 = new Mat(4, 4, MatType.CV_8UC3, Scalar.All(2));
                try {
                    camera.AddToShotQueue(left1);
                    camera.AddToShotQueue(left2);
                    camera.ResetQueue();
                    return new { d1 = left1.IsDisposed, d2 = left2.IsDisposed };
                } finally {
                    // 解放されていなかった場合(不具合時)の後始末
                    left1.Dispose();
                    left2.Dispose();
                }
            });

            // Assert
            using (Assert.EnterMultipleScope()) {
                Assert.That(obs.d1, Is.True, "残っていた 1 つ目の Mat が解放されること");
                Assert.That(obs.d2, Is.True, "残っていた 2 つ目の Mat が解放されること");
            }
        }

        /// <summary>
        /// 連続撮影(3 枚)で 1 枚目の画像保存に失敗したとき(保存スレッドが例外で保存ループを抜ける),
        /// 撮影した全てのフレーム(保存されなかった 2・3 枚目を含む)が解放され,撮影ボタンが再び有効になる.
        /// </summary>
        [Test, Category("Integration")]
        public void ShotMulti_1枚目の保存失敗_撮影した全フレームが解放され撮影ボタンが再有効化される() {
            // Arrange / Act
            var obs = IntegrationHarness.Run(h => {
                var camera = h.Connect(h.Track(new ProbeCamera(h.Form, h.ChartFrameFactory()) { BlockSaveNo = 1 }));
                h.Calibrate(camera);
                h.Form.SelfTestSetMultiShots("3", "0");
                var o = ShootMultiAndObserve(h, camera);
                o.CapturedDisposed = camera.Captured.Count(m => m.IsDisposed);
                return o;
            }, ShotOptions());

            // Assert
            Assert.That(obs.Errors, Is.Not.Empty, "前提: 保存失敗が通知されたこと(失敗注入が効いたこと)");
            Assert.That(obs.CaptureCalls, Is.EqualTo(3), "前提: 3 枚とも撮影されたこと");
            using (Assert.EnterMultipleScope()) {
                Assert.That(obs.CapturedDisposed, Is.EqualTo(obs.CaptureCalls), "撮影した全フレームが解放されること(保存されなかったフレームを含む)");
                Assert.That(obs.ShotsEnabled, Is.True, "保存失敗後も撮影ボタンが再び有効になること");
            }
        }

        // ================================================================================
        // 切断(保存中)
        // ================================================================================

        /// <summary>
        /// 連続撮影の直後(保存スレッドの動作中)に切断しても例外にならず,保存は最後まで行われ,
        /// 撮影ボタンが再び有効になり,チャートマスクが解放される.
        /// 保存スレッドは UI スレッドへの Invoke(進捗表示)を伴うため,UI スレッドがメッセージを処理するまでは
        /// 必ず生存している.撮影直後にメッセージを処理せずに切断することで「保存中の切断」を決定的に作る.
        /// </summary>
        [Test, Category("Integration")]
        public void 切断_保存中_例外にならず保存が完了し撮影ボタンが再有効化されマスクが解放される() {
            // 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");
                var masks = camera.ChartMasksSnapshot.ToList();
                h.Form.SelfTestClearLog();
                h.Form.EnableShots(false);

                camera.ShotMulti();
                var savingAtDisconnect = camera.IsSaveThreadStarted;
                string disconnectError = null;
                try {
                    camera.Disconnect();
                } catch (Exception ex) {
                    disconnectError = ex.ToString();
                }
                h.WaitSaveFinished(camera);

                var o = Observe(h, camera, null);
                o.SavingAtDisconnect = savingAtDisconnect;
                o.DisconnectError = disconnectError;
                o.MaskCount = masks.Count;
                o.MasksDisposed = masks.Count(m => m.IsDisposed);
                o.PreviewRunning = camera.IsPreviewRunning;
                return o;
            }, ShotOptions());

            // Assert
            Assert.That(obs.SavingAtDisconnect, Is.True, "前提: 保存スレッドの動作中に切断したこと");
            var expected = new List<string>();
            for (var no = 1; no <= 3; no++) expected.AddRange(obs.ExpectedFilesFor(no));
            using (Assert.EnterMultipleScope()) {
                Assert.That(obs.DisconnectError, Is.Null, "保存中の切断が例外にならないこと");
                Assert.That(obs.Errors, Is.Empty, $"保存中に切断しても保存は失敗しないこと.通知: [{string.Join(" / ", obs.Errors)}]");
                Assert.That(obs.SavedFiles, Is.SupersetOf(expected), "撮影済みの 3 枚は保存されること");
                Assert.That(obs.ShotsEnabled, Is.True, "保存完了後に撮影ボタンが再び有効になること");
                Assert.That(obs.MasksDisposed, Is.EqualTo(obs.MaskCount), "切断でチャートマスクが全て解放されること");
                Assert.That(obs.PreviewRunning, Is.False, "切断後にプレビューが動いていないこと");
            }
        }

        // ================================================================================
        // 共通処理
        // ================================================================================

        /// <summary>
        /// 撮影系テストの実行条件.保存時間の大半は変換次元ごとの sRGB 変換のため,変換次元を 1 つ(4)に絞って
        /// 層②全体の実行時間の目安(30 秒)に収める.全次元での保存はフェーズ 1 の CameraBaseSaveIntegrationTests が確認する.
        /// </summary>
        private static IntegrationOptions ShotOptions() {
            return new IntegrationOptions().WithConfig("Calib/ConversionChannels", "4");
        }

        /// <summary>
        /// 撮影ボタン押下(Form1.ExecuteShot)と同じく撮影ボタンを無効化してから連続撮影し,保存スレッドの終了を待って観測値を集める.
        /// </summary>
        private static ShotObservation ShootMultiAndObserve(IntegrationHarness h, ProbeCamera camera) {
            h.Form.SelfTestClearLog();
            h.Form.EnableShots(false);
            string shotError = null;
            try {
                camera.ShotMulti();
            } catch (Exception ex) {
                shotError = ex.ToString();
            }
            h.WaitSaveFinished(camera);
            return Observe(h, camera, shotError);
        }

        private static ShotObservation Observe(IntegrationHarness h, ProbeCamera camera, string shotError) {
            var folder = camera.CurrentSaveFolder;
            var obs = new ShotObservation {
                ShotError = shotError,
                Errors = h.Form.SelfTestErrors.ToList(),
                Messages = h.Form.SelfTestMessages.ToList(),
                ShotsEnabled = h.Form.SelfTestShotsEnabled,
                CaptureCalls = camera.CaptureCalls,
                InfoName = Config.GetString("File/Info"),
                RgbPattern = Config.GetString("File/RgbImage"),
                SrgbPattern = Config.GetString("File/SrgbImage"),
                Channels = GetConversionChannels(),
            };
            if (!string.IsNullOrEmpty(folder) && Directory.Exists(folder)) {
                obs.SavedFiles = Directory.GetFiles(folder).Select(Path.GetFileName).ToList();
                var info = Path.Combine(folder, obs.InfoName);
                if (File.Exists(info)) {
                    obs.InfoText = File.ReadAllText(info);
                    ParseShotLog(obs);
                }
            }
            return obs;
        }

        /// <summary>Info.csv を RFC 4180 として解釈し,撮影ログのヘッダ以降のレコードと生の行を取り出す</summary>
        private static void ParseShotLog(ShotObservation obs) {
            var records = ParseCsv(obs.InfoText);
            var headerIndex = records.FindIndex(r => r.SequenceEqual(ShotLogHeader));
            obs.ShotLogHeaderFound = headerIndex >= 0;
            if (headerIndex >= 0) {
                obs.ShotLogRows = records.Skip(headerIndex + 1).Where(r => !(r.Count == 1 && r[0] == "")).ToList();
            }
            var lines = obs.InfoText.Split(new[] { "\r\n" }, StringSplitOptions.None).ToList();
            var headerLine = lines.IndexOf(string.Join(",", ShotLogHeader));
            if (headerLine >= 0) obs.RawShotLines = lines.Skip(headerLine + 1).ToList();
        }

        /// <summary>
        /// RFC 4180 の CSV を解釈する(テスト用の最小実装).
        /// フィールド先頭の " から次の単独の " までを 1 フィールドとし("" は " 1 文字),その中のカンマ・改行は区切りにしない.
        /// 引用符で始まらないフィールド内の " は文字として扱う.レコード区切りは CRLF または LF.
        /// </summary>
        internal static List<List<string>> ParseCsv(string text) {
            var records = new List<List<string>>();
            var record = new List<string>();
            var field = new StringBuilder();
            var inQuotes = false;
            var fieldQuoted = false;
            var i = 0;
            Action endField = () => {
                record.Add(field.ToString());
                field.Clear();
                fieldQuoted = false;
            };
            Action endRecord = () => {
                endField();
                records.Add(record);
                record = new List<string>();
            };
            while (i < text.Length) {
                var c = text[i];
                if (inQuotes) {
                    if (c == '"') {
                        if (i + 1 < text.Length && text[i + 1] == '"') {
                            field.Append('"');
                            i += 2;
                            continue;
                        }
                        inQuotes = false;
                    } else {
                        field.Append(c);
                    }
                    i++;
                    continue;
                }
                if (c == '"' && field.Length == 0 && !fieldQuoted) {
                    inQuotes = true;
                    fieldQuoted = true;
                } else if (c == ',') {
                    endField();
                } else if (c == '\r' && i + 1 < text.Length && text[i + 1] == '\n') {
                    endRecord();
                    i++;
                } else if (c == '\n') {
                    endRecord();
                } else {
                    field.Append(c);
                }
                i++;
            }
            if (field.Length > 0 || record.Count > 0 || fieldQuoted) endRecord();
            return records;
        }

        /// <summary>設定の変換次元(Calib/ConversionChannels.SPEC_02: カンマ区切り・4〜17 の範囲のみ有効)</summary>
        private static List<int> GetConversionChannels() {
            return Config.GetString("Calib/ConversionChannels")
                .Split(',')
                .Select(x => int.Parse(x.Trim()))
                .Where(x => x >= 4 && x <= 17)
                .Distinct()
                .OrderBy(x => x)
                .ToList();
        }

        /// <summary>STA スレッドで集めた観測値(テストスレッドで検証する)</summary>
        private sealed class ShotObservation {
            public string ShotError;
            public List<string> Errors = new List<string>();
            public List<string> Messages = new List<string>();
            public bool ShotsEnabled;
            public int CaptureCalls;
            public int CapturedDisposed;
            public string InfoName;
            public string RgbPattern;
            public string SrgbPattern;
            public List<int> Channels = new List<int>();
            public List<string> SavedFiles = new List<string>();
            public string InfoText;
            public bool ShotLogHeaderFound;
            public List<List<string>> ShotLogRows = new List<List<string>>();
            public List<string> RawShotLines = new List<string>();
            public bool SavingAtDisconnect;
            public string DisconnectError;
            public int MaskCount;
            public int MasksDisposed;
            public bool PreviewRunning;

            /// <summary>SPEC_01 のファイル名規則({NO}: 連番 4 桁)での撮影画像名</summary>
            public string RgbFileFor(int no) => RgbPattern.Replace("{NO}", $"{no:0000}");

            /// <summary>連番 no の撮影画像と sRGB 変換画像(全次元)のファイル名({CN}: 変換次元 2 桁)</summary>
            public List<string> ExpectedFilesFor(int no) {
                var list = new List<string> { RgbFileFor(no) };
                list.AddRange(Channels.Select(ch => SrgbPattern.Replace("{CN}", $"{ch:00}").Replace("{NO}", $"{no:0000}")));
                return list;
            }
        }

        /// <summary>
        /// 撮影の観測と失敗注入のための偽カメラ.
        ///   - CaptureFrame の呼び出し回数と返した Mat を記録する(解放の確認用.所有権は従来どおり CameraBase 側)
        ///   - FailCaptureAt 枚目の CaptureFrame で CaptureFailedException(FailMessage) を投げる
        ///   - BlockSaveNo 番の撮影画像の保存先に同名フォルダを作り,保存を失敗させる(CameraBaseSaveIntegrationTests と同じ手段)
        ///   - 保存キューへの直接追加と ResetShotQueue の呼び出し口(ResetShotQueue の後始末の確認用)
        /// </summary>
        private sealed class ProbeCamera : SelfTestCamera {

            public ProbeCamera(Form1 form, Func<int, Mat> frameFactory) : base(form, frameFactory) { }

            /// <summary>CaptureFrame で失敗させる枚目(1 始まり.0 なら失敗させない)</summary>
            public int FailCaptureAt { get; set; }

            /// <summary>失敗させるときの例外メッセージ</summary>
            public string FailMessage { get; set; } = "注入: フレーム取得失敗";

            /// <summary>保存を失敗させる撮影画像の連番(1 始まり.0 なら失敗させない)</summary>
            public int BlockSaveNo { get; set; }

            /// <summary>CaptureFrame の呼び出し回数(失敗した回を含む)</summary>
            public int CaptureCalls { get; private set; }

            /// <summary>CaptureFrame が返した Mat(古い順)</summary>
            public List<Mat> Captured { get; } = new List<Mat>();

            public void AddToShotQueue(Mat mat) => _shots.Add(mat);

            public void ResetQueue() => ResetShotQueue();

            protected override void BeginShot() {
                base.BeginShot();
                if (BlockSaveNo > 0) {
                    var name = Config.GetString("File/RgbImage").Replace("{NO}", $"{BlockSaveNo:0000}");
                    Directory.CreateDirectory(Path.Combine(CurrentSaveFolder, name));
                }
            }

            protected override Mat CaptureFrame() {
                CaptureCalls++;
                if (FailCaptureAt > 0 && CaptureCalls == FailCaptureAt) {
                    throw new CaptureFailedException(FailMessage);
                }
                var frame = base.CaptureFrame();
                Captured.Add(frame);
                return frame;
            }
        }
    }
}
#endif