Newer
Older
TIASShot / TIASshot.Tests / IntegrationHarness.cs
#if SELFTEST
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Windows.Forms;
using NUnit.Framework;
using OpenCvSharp;

namespace TIASshot.Tests {

    /// <summary>
    /// 層②(結合ロジック)テストの実行条件.<see cref="IntegrationHarness.Run(Action{IntegrationHarness}, IntegrationOptions)"/> に渡す.
    /// 既定では校正を短縮する設定(<see cref="IntegrationHarness.FastConfig"/>)が入っている.
    /// </summary>
    internal sealed class IntegrationOptions {

        /// <summary>テスト本体(後始末を除く)の上限.既定 15 秒(testing.md「結合ロジック層のテスト」)</summary>
        public TimeSpan Timeout { get; set; } = IntegrationHarness.DefaultTimeout;

        /// <summary>
        /// 一時 config.xml に上書きする設定(キーは "Calib/Frames" のような Config のパス).
        /// 値が null の項目は要素ごと削除する(設定欠落の注入).存在しないキーに値を設定すると準備時に例外になる.
        /// </summary>
        public Dictionary<string, string> ConfigOverrides { get; } = CopyFastConfig();

        private static Dictionary<string, string> CopyFastConfig() {
            var copy = new Dictionary<string, string>();
            foreach (var kv in IntegrationHarness.FastConfig) copy[kv.Key] = kv.Value;
            return copy;
        }

        /// <summary>設定を 1 件上書きする(null で要素を削除).メソッドチェーン用に自身を返す</summary>
        public IntegrationOptions WithConfig(string key, string value) {
            ConfigOverrides[key] = value;
            return this;
        }

        /// <summary>テスト本体の上限を変える.メソッドチェーン用に自身を返す</summary>
        public IntegrationOptions WithTimeout(TimeSpan timeout) {
            Timeout = timeout;
            return this;
        }
    }

    /// <summary>
    /// 層②(結合ロジック)テストの共通ハーネス(SELFTEST 限定).testing.md「結合ロジック層のテスト」・PLAN_05 フェーズ 1.
    ///
    /// <para>
    /// <see cref="Run(Action{IntegrationHarness}, IntegrationOptions)"/> は次を行い,本体(body)を専用の STA スレッド上で実行する.
    /// 本体で起きた例外(NUnit のアサーション失敗を含む)はテストスレッドへ再送出する.
    /// </para>
    /// <list type="number">
    /// <item>一時フォルダを作り,同梱 config.xml を複製した一時設定(保存先も一時フォルダ)を Config.Load で読み込む
    /// (毎回読み直すため,前のテストの設定は残らない)</item>
    /// <item>ヘッドレス Form1 を生成し SelfTestStart(null) でウィンドウハンドルを作る(ワーカースレッドからの Invoke を実運用と同じ経路にする)</item>
    /// <item>本体を実行する</item>
    /// <item>後始末: 登録したカメラを停止・切断(プレビュー停止 → 保存スレッド終了 → Disconnect),
    /// SelfTestShutdown → Dispose,登録した IDisposable の解放,一時フォルダの削除</item>
    /// </list>
    ///
    /// <para>
    /// 待機は <see cref="PumpUntil"/>(メッセージを処理しながら条件成立を待つ.必ずタイムアウト付き)で行う.
    /// STA スレッドを Join・Sleep で止めると,SaveThread 等からの Control.Invoke が処理されず相互待機でハングする.
    /// 観測は Form1.SelfTest.cs のアクセサ(SelfTestMessage・SelfTestMessages・SelfTestLastError・SelfTestErrors・
    /// SelfTestShotsEnabled 等)と保存されたファイルで行う.
    /// </para>
    ///
    /// <para>
    /// 同梱の config.xml・本番の保存先(C:\TIAS_Data 等)には書き込まない(保存先は <see cref="SaveRoot"/> 配下).
    /// </para>
    ///
    /// 使用例:
    /// <code>
    /// [Test, Category("Integration")]
    /// public void ShotOne_保存成功_撮影ボタンが再有効化される() {
    ///     IntegrationHarness.Run(h => {
    ///         var camera = h.CreateChartCamera();
    ///         h.Calibrate(camera);
    ///         camera.ShotOne();
    ///         h.WaitSaveFinished(camera);
    ///         Assert.That(h.Form.SelfTestShotsEnabled, Is.True);
    ///         Assert.That(h.Form.SelfTestErrors, Is.Empty);
    ///     });
    /// }
    /// </code>
    /// </summary>
    internal sealed class IntegrationHarness {

        /// <summary>テスト本体の既定の上限(1 件あたり 15 秒.testing.md)</summary>
        public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);

        /// <summary>後始末(カメラ停止・フォーム破棄・一時フォルダ削除)に許す時間(本体の上限とは別枠)</summary>
        public static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);

        /// <summary>
        /// 層②の既定の設定上書き(1 件 1 秒前後で校正まで終わるよう短縮する).
        /// 同梱値: ChartSetCount=30・Frames=100・UpdateInterval=5.
        /// 合成チャートは静止しているため固定判定はすぐ成立し,ゲインも少ない更新で収束する.
        /// 収束の精度を検証するテストでは <see cref="IntegrationOptions.WithConfig"/> で戻すこと.
        /// </summary>
        public static readonly IReadOnlyDictionary<string, string> FastConfig = new Dictionary<string, string> {
            { "Calib/ChartSetCount", "3" },
            { "Calib/Frames", "10" },
            { "Calib/UpdateInterval", "2" },
        };

        // 校正のプレビューで流すフレーム数の余裕(固定判定・校正フレームに加えて,検出が遅れた場合の分)
        private const int CalibrationExtraFrames = 60;

        // Config(静的状態)と一時フォルダを複数のテストで同時に使わないための直列化
        private static readonly object RunLock = new object();

        private readonly Stopwatch _elapsed = Stopwatch.StartNew();
        private readonly TimeSpan _timeout;
        private readonly List<CameraBase> _cameras = new List<CameraBase>();
        private readonly List<IDisposable> _disposables = new List<IDisposable>();
        private Exception _uiException;
        private Func<int, Mat> _chartFrameFactory;

        // 現在待機中の内容(STA スレッドが応答しなくなった場合の失敗メッセージに使う)
        private volatile string _currentWait = "開始前";

        private IntegrationHarness(TimeSpan timeout) {
            _timeout = timeout;
        }

        // ================================================================================
        // 実行
        // ================================================================================

        /// <summary>
        /// 本体を STA スレッド上で実行する(準備・後始末込み).本体の例外はテストスレッドへ再送出する.
        /// </summary>
        /// <param name="body">テスト本体(引数のハーネスからフォーム・待機・カメラ生成を使う)</param>
        /// <param name="options">実行条件(null なら既定: 15 秒・<see cref="FastConfig"/>)</param>
        public static void Run(Action<IntegrationHarness> body, IntegrationOptions options = null) {
            Run<object>(h => {
                body(h);
                return null;
            }, options);
        }

        /// <summary>
        /// 本体を STA スレッド上で実行し,その戻り値を返す(観測値をテストスレッドで検証したい場合に使う).
        /// </summary>
        public static T Run<T>(Func<IntegrationHarness, T> body, IntegrationOptions options = null) {
            if (body == null) throw new ArgumentNullException(nameof(body));
            options = options ?? new IntegrationOptions();

            // TestContext はテストスレッドで参照する(STA スレッドへ実行コンテキストが流れる前提に依存しない)
            var sourceDir = FindSourceDir();

            lock (RunLock) {
                var harness = new IntegrationHarness(options.Timeout);
                var result = default(T);
                ExceptionDispatchInfo failure = null;

                var thread = new Thread(() => {
                    try {
                        result = harness.Execute(body, options, sourceDir);
                    } catch (Exception ex) {
                        failure = ExceptionDispatchInfo.Capture(ex);
                    }
                }) {
                    IsBackground = true,    // 応答しなくなってもテストプロセスの終了を妨げない
                    Name = "IntegrationHarness.STA",
                };
                thread.SetApartmentState(ApartmentState.STA);
                thread.Start();

                var limit = options.Timeout + CleanupTimeout;
                if (!thread.Join(limit)) {
                    throw new TimeoutException(
                        $"層②テストが {limit.TotalSeconds:0} 秒(本体 {options.Timeout.TotalSeconds:0} 秒+後始末 {CleanupTimeout.TotalSeconds:0} 秒)以内に終わりませんでした." +
                        $"STA スレッドが応答しません(最後の待機: {harness._currentWait}.Invoke の相互待機・モーダル表示・Join による UI スレッドの停止を疑う)." +
                        "一時フォルダが残っている可能性があります.");
                }
                failure?.Throw();
                return result;
            }
        }

        private T Execute<T>(Func<IntegrationHarness, T> body, IntegrationOptions options, string sourceDir) {
            // UI スレッドのメッセージ処理中の未処理例外をダイアログ表示ではなく記録にする(このスレッドのみに効く)
            Application.ThreadException += (s, e) => {
                Debug.WriteLine($"[IntegrationHarness] UI スレッドの未処理例外: {e.Exception}");
                if (_uiException == null) _uiException = e.Exception;
            };

            Exception primary = null;
            try {
                _currentWait = "一時設定の準備";
                WorkDir = Path.Combine(Path.GetTempPath(), "TIASshot-it", Guid.NewGuid().ToString("N"));
                var prepared = SelfTestConfig.Prepare(Path.Combine(sourceDir, "config.xml"), sourceDir, WorkDir, options.ConfigOverrides);
                if (!Config.IsLoaded()) throw new InvalidOperationException($"一時設定ファイルを読み込めません: {prepared.ConfigPath}");
                ConfigPath = prepared.ConfigPath;
                SaveRoot = prepared.SaveRoot;
                UserSaveFolder = prepared.UserSaveFolder;

                _currentWait = "ヘッドレス Form1 の生成";
                Form = new Form1(Form1.SelfTestHeadless.Instance);
                Form.SelfTestStart(null);

                _currentWait = "テスト本体";
                return body(this);
            } catch (Exception ex) {
                primary = ex;
                throw;
            } finally {
                Cleanup(primary);
            }
        }

        // ================================================================================
        // 公開: 環境
        // ================================================================================

        /// <summary>ヘッドレス Form1(SelfTestStart 済み.ハンドル生成済み)</summary>
        public Form1 Form { get; private set; }

        /// <summary>このテスト専用の一時フォルダ(後始末で削除される)</summary>
        public string WorkDir { get; private set; }

        /// <summary>一時設定の保存先(File/SaveFolder.<see cref="WorkDir"/> 配下)</summary>
        public string SaveRoot { get; private set; }

        /// <summary>読み込んだ一時 config.xml のパス</summary>
        public string ConfigPath { get; private set; }

        /// <summary>同梱 config.xml の File/SaveFolder(書き込んでいないことの確認用)</summary>
        public string UserSaveFolder { get; private set; }

        /// <summary>開始からの経過時間</summary>
        public TimeSpan Elapsed => _elapsed.Elapsed;

        /// <summary>テスト本体の上限までの残り時間(0 未満にはならない)</summary>
        public TimeSpan Remaining {
            get {
                var r = _timeout - _elapsed.Elapsed;
                return r > TimeSpan.Zero ? r : TimeSpan.Zero;
            }
        }

        // ================================================================================
        // 公開: 待機
        // ================================================================================

        /// <summary>
        /// 条件が成立するまでメッセージを処理して待つ.成立しなければ <see cref="TimeoutException"/> を投げる.
        /// 待機中に UI スレッドで未処理例外が起きた場合はその例外(を内包した例外)を投げる.
        /// </summary>
        /// <param name="condition">成立を待つ条件(STA スレッドで評価する)</param>
        /// <param name="what">待つ内容(失敗メッセージに使う.例: "保存スレッドの終了")</param>
        /// <param name="timeout">上限(省略時はテスト本体の残り時間)</param>
        public void PumpUntil(Func<bool> condition, string what, TimeSpan? timeout = null) {
            var limit = timeout ?? Remaining;
            var previous = _currentWait;
            _currentWait = what;
            try {
                var ok = SelfTestPump.PumpUntil(condition, limit, ThrowIfUiException);
                ThrowIfUiException();
                if (!ok) {
                    throw new TimeoutException(
                        $"{what} が {limit.TotalSeconds:0.0} 秒以内に成立しませんでした(開始から {Elapsed.TotalSeconds:0.0} 秒." +
                        $"表示「{Form?.SelfTestMessage}」,最後のエラー通知「{Form?.SelfTestLastError ?? "なし"}」)");
                }
            } finally {
                _currentWait = previous;
            }
        }

        /// <summary>指定時間だけメッセージを処理する(タイマー駆動の表示更新等を反映させる).残り時間を超えては待たない</summary>
        public void PumpFor(TimeSpan duration) {
            var d = duration < Remaining ? duration : Remaining;
            SelfTestPump.PumpFor(d, ThrowIfUiException);
            ThrowIfUiException();
        }

        private void ThrowIfUiException() {
            var ex = _uiException;
            if (ex == null) return;
            _uiException = null;
            throw new InvalidOperationException($"UI スレッドのメッセージ処理中に未処理例外が発生しました: {ex.Message}", ex);
        }

        // ================================================================================
        // 公開: カメラ・資源
        // ================================================================================

        /// <summary>
        /// カメラを後始末の対象に登録する(終了時にプレビュー停止 → 保存スレッド終了待ち → Disconnect).
        /// テスト内で CameraBase を派生させたカメラもこれで登録する.
        /// </summary>
        public T Track<T>(T camera) where T : CameraBase {
            if (camera != null) _cameras.Add(camera);
            return camera;
        }

        /// <summary>資源を後始末で解放する対象に登録する(Form1 の破棄後,登録の逆順に Dispose)</summary>
        public T AddDisposable<T>(T disposable) where T : IDisposable {
            if (disposable != null) _disposables.Add(disposable);
            return disposable;
        }

        /// <summary>
        /// 合成の舌診チャート(SelfTestChart.色票は参照値 tcc_srgb を基にする)のフレームを返す生成関数.
        /// 呼び出しごとに新しい Mat(所有権は呼び出し側=偽カメラ)を返す.元のチャート画像はハーネスが解放する.
        /// </summary>
        public Func<int, Mat> ChartFrameFactory() {
            if (_chartFrameFactory == null) {
                var tccSrgb = LoadTccSrgb();
                var chart = AddDisposable(SelfTestChart.CreateChartFrame(tccSrgb));
                _chartFrameFactory = i => chart.Clone();
            }
            return _chartFrameFactory;
        }

        /// <summary>参照値 tcc_srgb.csv(24x3)を読み込む(後始末で解放される)</summary>
        public Mat LoadTccSrgb() {
            var path = Config.GetString("File/TccSrgbRef");
            var m = IoUtil.LoadMatFromCsv(path);
            if (m == null) throw new InvalidOperationException($"参照ファイルを読み込めません: {path}");
            return AddDisposable(m);
        }

        /// <summary>合成チャートを流す偽カメラを生成・登録し,接続(BootCheck)する</summary>
        public SelfTestCamera CreateChartCamera() {
            return CreateCamera(ChartFrameFactory());
        }

        /// <summary>任意のフレームを流す偽カメラを生成・登録し,接続(BootCheck)する</summary>
        /// <param name="frameFactory">フレーム番号から新しい Mat を返す関数(戻り値の所有権は偽カメラへ移る)</param>
        public SelfTestCamera CreateCamera(Func<int, Mat> frameFactory) {
            return Connect(Track(new SelfTestCamera(Form, frameFactory)));
        }

        /// <summary>カメラを接続する.失敗したら ErrorMsg を含む例外を投げる</summary>
        public T Connect<T>(T camera) where T : CameraBase {
            if (!camera.Connect()) throw new InvalidOperationException($"カメラの接続に失敗しました: {camera.ErrorMsg}");
            return camera;
        }

        /// <summary>
        /// 偽カメラのプレビューを流して自動校正(チャート検出 → 固定判定 → ホワイトバランス → 変換行列算出)を完了させる.
        /// 完了しなければ例外を投げる.完了時点で撮影ボタンは有効化されている(CalcTcc → EnableShots).
        /// </summary>
        public void Calibrate(SelfTestCamera camera) {
            var maxFrames = Config.GetInt("Calib/ChartSetCount") + Config.GetInt("Calib/Frames") + CalibrationExtraFrames;
            camera.StartPreview(maxFrames, 0, true);
            PumpUntil(() => !camera.IsPreviewRunning, "校正のプレビュー終了");
            if (camera.PreviewError != null) {
                throw new InvalidOperationException($"校正中のプレビューで例外が発生しました: {camera.PreviewError.Message}", camera.PreviewError);
            }
            if (!camera.IsCalibrated) {
                throw new InvalidOperationException(
                    $"校正が完了しませんでした({camera.FramesDelivered} フレーム.表示「{Form.SelfTestMessage}」.チャート検出の失敗を疑う)");
            }
            PumpUntil(() => Form.SelfTestShotsEnabled, "校正完了後の撮影ボタン有効化");
        }

        /// <summary>
        /// 撮影の保存スレッド(SaveThread)の終了を待つ.終了時点で SaveThread からの通知
        /// (ShowMessage・ShowError・EnableShots の Invoke)は処理済みである.
        /// </summary>
        public void WaitSaveFinished(CameraBase camera, TimeSpan? timeout = null) {
            PumpUntil(() => !camera.IsSaveThreadStarted, "保存スレッドの終了", timeout);
        }

        // ================================================================================
        // 後始末
        // ================================================================================

        private void Cleanup(Exception primary) {
            var deadline = Stopwatch.StartNew();
            var errors = new List<string>();
            Func<TimeSpan> left = () => {
                var r = CleanupTimeout - deadline.Elapsed;
                return r > TimeSpan.Zero ? r : TimeSpan.Zero;
            };
            _currentWait = "後始末";

            for (var i = _cameras.Count - 1; i >= 0; i--) {
                var camera = _cameras[i];
                try {
                    var fake = camera as SelfTestCamera;
                    if (fake != null) {
                        fake.RequestStopPreview();
                        // プレビュースレッドは Invoke で UI スレッドを待つことがあるため,Join ではなくメッセージを回して待つ
                        if (!SelfTestPump.PumpUntil(() => !fake.IsPreviewRunning, left())) errors.Add("後始末: 偽カメラのプレビューが停止しません");
                    }
                    if (!SelfTestPump.PumpUntil(() => !camera.IsSaveThreadStarted, left())) errors.Add("後始末: 保存スレッドが終了しません");
                    camera.Disconnect();
                } catch (Exception ex) {
                    errors.Add($"後始末: カメラの切断に失敗しました: {ex}");
                }
            }

            if (Form != null) {
                try {
                    Form.SelfTestShutdown();
                    Form.Dispose();
                } catch (Exception ex) {
                    errors.Add($"後始末: Form1 の破棄に失敗しました: {ex}");
                }
            }

            for (var i = _disposables.Count - 1; i >= 0; i--) {
                try {
                    _disposables[i].Dispose();
                } catch (Exception ex) {
                    errors.Add($"後始末: 資源の解放に失敗しました: {ex}");
                }
            }

            if (_uiException != null) {
                errors.Add($"後始末: UI スレッドの未処理例外(未検査): {_uiException}");
            }

            DeleteWorkDir();

            if (errors.Count == 0) return;
            var message = string.Join(Environment.NewLine, errors);
            if (primary != null) {
                // 本体の失敗を優先して報告する(後始末の失敗はログに残す)
                Debug.WriteLine($"[IntegrationHarness] {message}");
                TestContext.Progress.WriteLine($"[IntegrationHarness] {message}");
                return;
            }
            throw new InvalidOperationException(message);
        }

        private void DeleteWorkDir() {
            if (string.IsNullOrEmpty(WorkDir) || !Directory.Exists(WorkDir)) return;
            try {
                Directory.Delete(WorkDir, true);
            } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) {
                // 削除できなくてもテストの判定には影響しない(%TEMP% 配下のため手動削除可能)
                Debug.WriteLine($"[IntegrationHarness] 一時フォルダを削除できませんでした: {WorkDir}: {ex.Message}");
                TestContext.Progress.WriteLine($"[IntegrationHarness] 一時フォルダを削除できませんでした: {WorkDir}: {ex.Message}");
            }
        }

        /// <summary>同梱 config.xml・参照 CSV のあるフォルダ(リポジトリの TIASshot/.読むだけ)</summary>
        private static string FindSourceDir() {
            var dir = Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "TIASshot"));
            if (!File.Exists(Path.Combine(dir, "config.xml"))) {
                throw new FileNotFoundException($"同梱 config.xml が見つかりません(TIASshot.Tests/bin/x64/<構成>/ から見たリポジトリの TIASshot/ を想定): {dir}");
            }
            return dir;
        }
    }
}
#endif