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 の切断・終了」
    ///   - プレビュー中に舌診チャートを検出すると自動でキャリブレーションを開始する
    ///   - キャリブレーション完了後に撮影ボタンが有効になる
    ///   - 算出した変換行列等は保存フォルダに保存される
    ///   - 校正の途中で失敗したときは,再検出できる状態へ戻る
    ///   - 切断時はチャートマスク(ネイティブ資源)を解放する(CLAUDE.md: Mat は確実に解放する)
    ///
    /// 失敗の注入:
    ///   - チャートの映っていない一様なフレーム(SelfTestChart.CreatePlainFrame)を流す
    ///   - 校正の収束処理(LogCalibrationConverged)で 1 回だけ例外を投げる派生偽カメラ
    ///
    /// 観測は 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>
        /// 校正の途中(収束処理)で例外が起きた後,再びプレビューを流すとチャートを再検出して校正を完了でき,
        /// 撮影ボタンが有効になる(途中で失敗したときに再検出できる状態へ戻る).
        /// </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 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, "再校正の完了で撮影ボタンが有効になること");
            }
        }

        /// <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 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, "使用中の新しいマスクは解放されていないこと");
            }
        }

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

        /// <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, "校正の途中で切断したら撮影ボタンは無効のままであること");
            }
        }

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

        /// <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 回目だけ例外を投げる偽カメラ(失敗注入用).
        /// 例外はプレビュースレッドで捕捉され PreviewError に記録される(プレビューは止まる).
        /// </summary>
        private sealed class ConvergeFailOnceCamera : SelfTestCamera {

            private int _calls;

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

            /// <summary>注入した例外(PreviewError と同一インスタンスかで注入の成立を確かめる)</summary>
            public InvalidOperationException Injected { get; } = new InvalidOperationException("注入: 校正の収束処理で失敗");

            protected override void LogCalibrationConverged() {
                if (Interlocked.Increment(ref _calls) == 1) throw Injected;
                base.LogCalibrationConverged();
            }
        }
    }
}
#endif