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

    /// <summary>
    /// CameraBase の自動校正(ProcessCalibrationFrame → DetectChart → 固定判定 → ホワイトバランス → CalcTcc)と,
    /// 校正後・校正中の切断の結合ロジックテスト(層②).
    ///
    /// 仕様: SPEC_01「自動キャリブレーション」「撮影」「データ保存(キャリブレーション保存ファイル)」・
    /// testing.md「層②: CameraBase の校正」「CameraBase の切断・終了」
    ///   - プレビュー中に舌診チャートを検出すると自動でキャリブレーションを開始する
    ///   - キャリブレーション完了後に撮影ボタンが有効になる
    ///   - 算出した変換行列等は保存フォルダに保存される
    ///   - 校正の途中で失敗したときは,再検出できる状態へ戻る
    ///   - PLAN_05「束 B の方針」: 校正中の例外はカメラのコールバックへ伝えず,「校正に失敗しました」と通知して
    ///     未校正・マスク解放に戻す.次に校正が完了するまで検出中の表示を失敗の表示に差し替える.
    ///     校正画像の保存に失敗しても校正済みとし,「自動校正完了(校正画像の保存に失敗)」と警告する
    ///   - 切断時はチャートマスク(ネイティブ資源)を解放する(CLAUDE.md: Mat は確実に解放する)
    ///
    /// 失敗の注入:
    ///   - チャートの映っていない一様なフレーム(SelfTestChart.CreatePlainFrame)を流す
    ///   - 校正の収束処理(LogCalibrationConverged)で 1 回だけ例外を投げる派生偽カメラ
    ///   - 校正の更新間隔 0(校正フレームごとに 0 除算の例外.設定不備)
    ///   - 校正画像(File/TccImage)に 255 文字を超えるファイル名(保存が false で失敗する)
    ///
    /// 観測は SelfTestCamera のアクセサ(IsCalibrated・PreviewError・ChartMasksSnapshot・CalibrationFolder)・
    /// Form1.SelfTest.cs のアクセサ(SelfTestShotsEnabled)・保存されたファイルで行う.
    /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる.
    /// </summary>
    [TestFixture]
    public class CameraBaseCalibrationIntegrationTests {

        // 校正のプレビューで流すフレーム数の余裕(ハーネスの Calibrate と同じ考え方)
        private const int ExtraFrames = 60;

        // 舌診チャートの色票数(SPEC_01: 24 色票)
        private const int PatchCount = 24;

        // ================================================================================
        // 校正の状態遷移(正常系)
        // ================================================================================

        /// <summary>
        /// 舌診チャートを流すと校正が完了し,撮影ボタンが無効から有効に変わる.
        /// </summary>
        [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, "校正完了後に撮影ボタンが有効になること");
            }
        }

        /// <summary>
        /// 校正が完了すると,SPEC_01「キャリブレーション保存ファイル」に挙げたファイルが校正の保存フォルダに作られる.
        /// </summary>
        [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<string> {
                    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<string>();
                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)}]");
            }
        }

        // ================================================================================
        // 校正の状態遷移(失敗注入)
        // ================================================================================

        /// <summary>
        /// チャートの映っていないフレームだけを流すと,校正は完了せず,撮影ボタンは無効のままである.
        /// </summary>
        [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, "校正が完了していないので撮影ボタンは無効のままであること");
            }
        }

        /// <summary>
        /// 校正の途中(収束処理)で例外が起きても,例外はカメラのコールバック(プレビュースレッド)へ伝わらず,
        /// 「校正に失敗しました」がちょうど 1 回通知され,同じプレビューのままチャートを再検出して校正を完了でき,
        /// 撮影ボタンが有効になる(途中で失敗したときに再検出できる状態へ戻る.PLAN_05「束 B の方針」).
        /// </summary>
        [Test, Category("Integration")]
        public void 校正_途中で例外_同じプレビューのまま再検出して校正が完了する() {
            // Arrange / Act
            var obs = IntegrationHarness.Run(h => {
                var camera = h.Connect(h.Track(new ConvergeFailOnceCamera(h.Form, h.ChartFrameFactory())));
                h.Form.SelfTestClearLog();
                RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
                h.PumpUntil(() => h.Form.SelfTestShotsEnabled || !camera.IsCalibrated, "撮影ボタン有効化の反映", TimeSpan.FromSeconds(3));
                var messages = h.Form.SelfTestMessages.ToList();
                return new {
                    camera.ConvergeCalls,
                    PreviewError = camera.PreviewError?.ToString(),
                    notices = messages.Count(IsFailureNotice),
                    noticeHasReason = messages.Where(IsFailureNotice).All(m => m.Contains(camera.Injected.Message)),
                    calibrated = camera.IsCalibrated,
                    enabled = h.Form.SelfTestShotsEnabled,
                    messages,
                };
            });

            // Assert
            Assert.That(obs.ConvergeCalls, Is.GreaterThanOrEqualTo(2), "前提: 1 回目の収束処理で失敗し,同じプレビューの中で 2 回目の収束処理に到達したこと");
            using (Assert.EnterMultipleScope()) {
                Assert.That(obs.PreviewError, Is.Null, "校正中の例外がカメラのコールバック(プレビュースレッド)へ伝わらないこと");
                Assert.That(obs.notices, Is.EqualTo(1), $"「校正に失敗しました」がちょうど 1 回通知されること.表示履歴: [{string.Join(" / ", obs.messages)}]");
                Assert.That(obs.noticeHasReason, Is.True, "失敗の通知に理由(例外メッセージ)が含まれること");
                Assert.That(obs.calibrated, Is.True, "同じプレビューのまま再検出して校正が完了すること");
                Assert.That(obs.enabled, Is.True, "再校正の完了で撮影ボタンが有効になること");
            }
        }

        /// <summary>
        /// 校正の途中で例外が起きたとき,失敗した校正で作られた旧チャートマスクは全て解放される
        /// (未校正・マスク解放の状態へ戻す.マスクを Dispose せずに捨てるとネイティブメモリがリークする).
        /// 旧マスクは例外を投げる直前に控える.
        /// </summary>
        [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 oldMasks = camera.MasksAtFailure ?? new List<Mat>();
                var newMasks = camera.ChartMasksSnapshot.ToList();
                return new {
                    PreviewError = camera.PreviewError?.ToString(),
                    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.oldCount, Is.EqualTo(PatchCount), "前提: 失敗した校正で 24 色票ぶんのマスクが作られていたこと(注入が効いたこと)");
            Assert.That(obs.calibrated, Is.True, $"前提: 再検出・校正が完了したこと(PreviewError: {obs.PreviewError ?? "なし"})");
            using (Assert.EnterMultipleScope()) {
                Assert.That(obs.oldDisposed, Is.EqualTo(obs.oldCount), "失敗した校正の旧マスクが全て解放されること");
                Assert.That(obs.replaced, Is.True, "再検出のマスクは旧マスクと別のものであること");
                Assert.That(obs.newCount, Is.EqualTo(PatchCount), "再検出で 24 色票ぶんのマスクがあること");
                Assert.That(obs.newDisposed, Is.Zero, "使用中の新しいマスクは解放されていないこと");
            }
        }

        // ================================================================================
        // 校正中の例外(失敗注入: 毎フレーム例外になる設定)
        // ================================================================================

        /// <summary>
        /// 校正の更新間隔が 0(設定不備.校正フレームごとに 0 除算の例外)のとき,例外はカメラのコールバックへ伝わらず,
        /// 同じ失敗を何度繰り返しても「校正に失敗しました」の通知はちょうど 1 回で,エラーダイアログは出ず,校正は完了しない.
        /// </summary>
        [Test, Category("Integration")]
        public void 校正_毎フレーム例外_コールバックへ伝わらず失敗の通知はちょうど1回() {
            // Arrange / Act
            var obs = IntegrationHarness.Run(h => {
                var camera = h.CreateChartCamera();
                h.Form.SelfTestClearLog();
                RunPreview(h, camera, AlwaysFailFrames, stopWhenCalibrated: true);
                var messages = h.Form.SelfTestMessages.ToList();
                return new {
                    camera.FramesDelivered,
                    PreviewError = camera.PreviewError?.ToString(),
                    notices = messages.Count(IsFailureNotice),
                    retrying = messages.Count(IsRetryingMessage),
                    errors = h.Form.SelfTestErrors.ToList(),
                    calibrated = camera.IsCalibrated,
                    enabled = h.Form.SelfTestShotsEnabled,
                    messages,
                };
            }, AlwaysFailOptions());

            // Assert
            Assert.That(obs.FramesDelivered, Is.EqualTo(AlwaysFailFrames), "前提: 上限までフレームを流したこと(プレビューが止まらなかったこと)");
            Assert.That(obs.retrying, Is.GreaterThanOrEqualTo(4), $"前提: 失敗の後も検出と失敗を繰り返したこと.表示履歴: [{string.Join(" / ", obs.messages)}]");
            using (Assert.EnterMultipleScope()) {
                Assert.That(obs.PreviewError, Is.Null, "校正中の例外がカメラのコールバック(プレビュースレッド)へ伝わらないこと");
                Assert.That(obs.notices, Is.EqualTo(1), $"同じ失敗の通知は繰り返さず,ちょうど 1 回であること.表示履歴: [{string.Join(" / ", obs.messages)}]");
                Assert.That(obs.errors, Is.Empty, "エラーダイアログは使わないこと(カメラのコールバックを止めるため)");
                Assert.That(obs.calibrated, Is.False, "失敗し続ける校正は完了扱いにならないこと");
                Assert.That(obs.enabled, Is.False, "校正が完了していないので撮影ボタンは無効のままであること");
            }
        }

        /// <summary>
        /// 校正が失敗した後もチャートを流し続けたとき,次に校正が完了するまで,検出中の表示(「舌診チャートの検出中」等)で
        /// 失敗の表示が上書きされず,最後の表示が「校正に失敗しました(理由).チャートの検出からやり直しています」である(案 A).
        /// </summary>
        [Test, Category("Integration")]
        public void 校正_失敗後もチャートを流す_検出中の表示に上書きされず失敗の表示が続く() {
            // Arrange / Act
            var obs = IntegrationHarness.Run(h => {
                var camera = h.CreateChartCamera();
                h.Form.SelfTestClearLog();
                RunPreview(h, camera, AlwaysFailFrames, stopWhenCalibrated: true);
                var messages = h.Form.SelfTestMessages.ToList();
                var first = messages.FindIndex(m => IsFailureNotice(m));
                return new {
                    first,
                    afterFailure = first < 0 ? new List<string>() : messages.Skip(first + 1).ToList(),
                    last = h.Form.SelfTestMessage,
                    messages,
                };
            }, AlwaysFailOptions());

            // Assert
            Assert.That(obs.first, Is.GreaterThanOrEqualTo(0), $"前提: 校正の失敗が通知されたこと.表示履歴: [{string.Join(" / ", obs.messages)}]");
            Assert.That(obs.afterFailure, Is.Not.Empty, "前提: 失敗の後もチャート検出の表示が出たこと");
            using (Assert.EnterMultipleScope()) {
                Assert.That(obs.afterFailure, Has.None.Matches<string>(IsPlainDetectionMessage),
                    $"失敗の後は検出中の表示がそのまま出ない(失敗の表示が上書きされない)こと.失敗後の表示: [{string.Join(" / ", obs.afterFailure)}]");
                Assert.That(obs.last, Does.StartWith("校正に失敗しました("), "最後の表示が校正失敗の表示であること");
                Assert.That(obs.last, Does.EndWith(RetryingSuffix), "最後の表示が「…チャートの検出からやり直しています」であること");
            }
        }

        /// <summary>
        /// 校正の途中で失敗した後,同じプレビューの中で再校正が完了すると,表示は「自動校正完了」になる.
        /// 失敗から完了までの間は検出中の表示で失敗の表示が上書きされない.
        /// </summary>
        [Test, Category("Integration")]
        public void 校正_失敗後に再校正が完了_表示が自動校正完了になる() {
            // Arrange / Act
            var obs = IntegrationHarness.Run(h => {
                var camera = h.Connect(h.Track(new ConvergeFailOnceCamera(h.Form, h.ChartFrameFactory())));
                h.Form.SelfTestClearLog();
                RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
                var messages = h.Form.SelfTestMessages.ToList();
                var first = messages.FindIndex(m => IsFailureNotice(m));
                var done = messages.FindIndex(m => m == "自動校正完了");
                return new {
                    calibrated = camera.IsCalibrated,
                    first,
                    done,
                    between = first >= 0 && done > first ? messages.Skip(first + 1).Take(done - first - 1).ToList() : new List<string>(),
                    last = h.Form.SelfTestMessage,
                    messages,
                };
            });

            // Assert
            Assert.That(obs.first, Is.GreaterThanOrEqualTo(0), $"前提: 校正の失敗が通知されたこと.表示履歴: [{string.Join(" / ", obs.messages)}]");
            Assert.That(obs.calibrated, Is.True, "前提: 再校正が完了したこと");
            using (Assert.EnterMultipleScope()) {
                Assert.That(obs.last, Is.EqualTo("自動校正完了"), "再校正が完了したら表示が「自動校正完了」になること");
                Assert.That(obs.done, Is.GreaterThan(obs.first), "「自動校正完了」は失敗の通知より後に出ること");
                Assert.That(obs.between, Has.None.Matches<string>(IsPlainDetectionMessage),
                    $"失敗から再校正の完了までは検出中の表示で失敗の表示が上書きされないこと.間の表示: [{string.Join(" / ", obs.between)}]");
            }
        }

        // ================================================================================
        // 校正画像の保存失敗(失敗注入: 255 文字を超えるファイル名)
        // ================================================================================

        /// <summary>
        /// 校正画像(TccImage)の保存に失敗しても(ファイル名が長すぎて Cv2.ImWrite が false を返す),
        /// 変換行列はメモリ上で有効なため校正済みとなり撮影ボタンが有効になり,表示は「自動校正完了(校正画像の保存に失敗)」になる.
        /// 前提(例外ではなく false になること)は IoUtilSaveImageTests で固定している.
        /// </summary>
        [Test, Category("Integration")]
        public void 校正_校正画像の保存失敗_校正済みのまま保存失敗の警告を表示する() {
            // Arrange
            var longName = new string('a', 260) + ".png";
            var options = new IntegrationOptions().WithConfig("File/TccImage", longName);

            // Act
            var obs = IntegrationHarness.Run(h => {
                var camera = h.CreateChartCamera();
                h.Form.SelfTestClearLog();
                RunPreview(h, camera, MaxCalibrationFrames(), stopWhenCalibrated: true);
                h.PumpUntil(() => h.Form.SelfTestShotsEnabled || !camera.IsCalibrated, "撮影ボタン有効化の反映", TimeSpan.FromSeconds(3));
                var folder = camera.CalibrationFolder;
                return new {
                    PreviewError = camera.PreviewError?.ToString(),
                    tccSaved = !string.IsNullOrEmpty(folder) && File.Exists(Path.Combine(folder, longName)),
                    calibrated = camera.IsCalibrated,
                    enabled = h.Form.SelfTestShotsEnabled,
                    last = h.Form.SelfTestMessage,
                    messages = h.Form.SelfTestMessages.ToList(),
                };
            }, options);

            // Assert
            Assert.That(obs.tccSaved, Is.False, "前提: 校正画像の保存に失敗したこと(注入が効いたこと)");
            using (Assert.EnterMultipleScope()) {
                Assert.That(obs.PreviewError, Is.Null, "校正画像の保存失敗がカメラのコールバックへ伝わらないこと");
                Assert.That(obs.calibrated, Is.True, "校正画像の保存に失敗しても校正済みとなること");
                Assert.That(obs.enabled, Is.True, "校正画像の保存に失敗しても撮影ボタンが有効になること");
                Assert.That(obs.last, Is.EqualTo("自動校正完了(校正画像の保存に失敗)"),
                    $"保存失敗の警告つきの完了表示であること.表示履歴: [{string.Join(" / ", obs.messages)}]");
            }
        }

        // ================================================================================
        // 切断
        // ================================================================================

        /// <summary>
        /// 校正完了後に切断すると,チャートマスクが全て解放され,カメラの保持するマスクが空になる.
        /// </summary>
        [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, "切断後はマスクを保持しないこと");
            }
        }

        /// <summary>
        /// 校正の途中(チャート検出後・変換行列算出前)にプレビューを止めて切断しても例外にならず,
        /// チャートマスクが全て解放され,校正完了扱い・撮影ボタン有効化にはならない.
        /// 校正フレーム数を大きくして,校正中の状態(IsCalibrating)を確実に作ってから切断する.
        /// </summary>
        [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, "校正の途中で切断したら撮影ボタンは無効のままであること");
            }
        }

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

        // 校正失敗後に検出中の表示を差し替える文の末尾(PLAN_05「束 B の方針」: 「校正に失敗しました(理由).チャートの検出からやり直しています」)
        private const string RetryingSuffix = ".チャートの検出からやり直しています";

        // 毎フレーム例外になる設定で流すフレーム数(固定判定 3 フレームの後,2 フレームごとに検出と失敗を繰り返す)
        private const int AlwaysFailFrames = 20;

        /// <summary>校正フレームごとに例外になる設定(更新間隔 0 → 0 除算.設定不備の注入)</summary>
        private static IntegrationOptions AlwaysFailOptions() {
            return new IntegrationOptions().WithConfig("Calib/UpdateInterval", "0");
        }

        /// <summary>校正失敗の通知か(「校正に失敗しました」で始まり,検出中の表示の差し替え文ではないもの)</summary>
        private static bool IsFailureNotice(string m) {
            return m != null && m.StartsWith("校正に失敗しました", StringComparison.Ordinal) && !m.EndsWith(RetryingSuffix, StringComparison.Ordinal);
        }

        /// <summary>校正失敗後に検出中の表示を差し替えた文か</summary>
        private static bool IsRetryingMessage(string m) {
            return m != null && m.StartsWith("校正に失敗しました(", StringComparison.Ordinal) && m.EndsWith(RetryingSuffix, StringComparison.Ordinal);
        }

        /// <summary>チャート検出の進捗表示(差し替えられていないもの)か</summary>
        private static bool IsPlainDetectionMessage(string m) {
            return m == "舌診チャートの検出中" || m == "舌診チャート検出 校正中";
        }

        /// <summary>プレビューを流し,停止するまでメッセージを処理して待つ(PreviewError は判定しない)</summary>
        private static void RunPreview(IntegrationHarness h, SelfTestCamera camera, int maxFrames, bool stopWhenCalibrated) {
            camera.StartPreview(maxFrames, 0, stopWhenCalibrated);
            h.PumpUntil(() => !camera.IsPreviewRunning, "プレビューの終了");
        }

        /// <summary>校正に必要なフレーム数+余裕(一時設定が読み込まれている STA スレッドで呼ぶ)</summary>
        private static int MaxCalibrationFrames() {
            return Config.GetInt("Calib/ChartSetCount") + Config.GetInt("Calib/Frames") + ExtraFrames;
        }

        /// <summary>ハーネスの既定設定(FastConfig)での MaxCalibrationFrames(テストスレッドで期待値に使う)</summary>
        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}");
        }

        /// <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();
        }

        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);
        }

        /// <summary>
        /// 校正の収束処理(LogCalibrationConverged.CalcTcc の直前に呼ばれる)で 1 回目だけ例外を投げる偽カメラ(失敗注入用).
        /// 新方針(PLAN_05「束 B の方針」)では例外はカメラのコールバックへ伝わらず,同じプレビューのまま再検出される.
        /// 例外を投げる直前のチャートマスク(失敗した校正のマスク)を控える(解放の確認用).
        /// </summary>
        private sealed class ConvergeFailOnceCamera : SelfTestCamera {

            private int _calls;

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

            /// <summary>注入した例外</summary>
            public InvalidOperationException Injected { get; } = new InvalidOperationException("注入: 校正の収束処理で失敗");

            /// <summary>収束処理の呼び出し回数(失敗した回を含む)</summary>
            public int ConvergeCalls => Volatile.Read(ref _calls);

            /// <summary>例外を投げる直前のチャートマスクの参照のコピー(注入前は null)</summary>
            public List<Mat> MasksAtFailure { get; private set; }

            protected override void LogCalibrationConverged() {
                if (Interlocked.Increment(ref _calls) == 1) {
                    // プレビュースレッド(_chartMasks を書き換える唯一のスレッド)上なので,ここで読んでよい
                    MasksAtFailure = ChartMasksSnapshot.ToList();
                    throw Injected;
                }
                base.LogCalibrationConverged();
            }
        }
    }
}
#endif