Newer
Older
TIASShot / TIASshot / SelfTest / SelfTestRunner.cs
#if SELFTEST
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Windows.Forms;
using System.Xml;
using OpenCvSharp;
using OpenCvSharp.Aruco;
using OpenCvSharp.Extensions;

namespace TIASshot {

    /// <summary>
    /// ヘッドレスのセルフテスト(SELFTEST 限定).<c>TIASshot.exe --selftest</c> で起動すると,
    /// フォームを表示せずに次の 3 項目を実行し,終了コードと結果ファイル(+標準出力)で結果を返す.
    /// <list type="number">
    /// <item>deps: 依存アセンブリのロード(バインディングリダイレクト含む)と OpenCvSharp の最小実行</item>
    /// <item>preview: 偽カメラ → Form1.ShowImage(FastClone)→ 本モニタ/副モニタの表示用 Bitmap の経路</item>
    /// <item>calib-save: 合成チャートでチャート検出 → 色変換行列算出 → 撮影 → sRGB 変換画像の保存</item>
    /// </list>
    /// 実カメラ SDK には触れない.保存先は一時フォルダに差し替え,ユーザーの設定・データは書き換えない.
    ///
    /// 引数:
    ///   --selftest                 セルフテストを実行する
    ///   --selftest-out &lt;path&gt;     結果ファイルの出力先(省略時は %TEMP%\TIASshot-selftest\result-&lt;日時&gt;.txt)
    ///   --selftest-keep            合格時も作業フォルダ(保存画像・CSV)を削除しない
    /// 終了コード: 0=全項目合格,1=不合格あり,2=セルフテスト自体の異常,3=タイムアウト
    ///
    /// 【注意】本クラスのメソッド本体・フィールドでは OpenCvSharp 等の依存型を直接使わず,
    /// 各検査はラムダ(別メソッドとして遅延 JIT される)の中で使う.依存 DLL が欠けていても
    /// JIT 失敗が検査単位の例外として捕捉され,どの依存が欠けたかを報告できるようにするため.
    /// </summary>
    internal static class SelfTestRunner {

        public const string FlagSelfTest = "--selftest";
        private const string FlagOut = "--selftest-out";
        private const string FlagKeep = "--selftest-keep";

        public const int ExitPass = 0;
        public const int ExitFail = 1;
        public const int ExitError = 2;
        public const int ExitTimeout = 3;

        private static readonly TimeSpan OverallTimeout = TimeSpan.FromMinutes(5);
        private static readonly TimeSpan PreviewTimeout = TimeSpan.FromSeconds(30);
        private static readonly TimeSpan CalibrationTimeout = TimeSpan.FromSeconds(120);
        private static readonly TimeSpan SaveTimeout = TimeSpan.FromSeconds(60);

        // カメラ SDK 由来のアセンブリ(SDK 未導入環境ではロードできなくても判定に含めない)
        private static readonly string[] CameraSdkAssemblyPrefixes = { "TIS.", "lumenera." };

        // 必ずロードできなければならない依存(NuGet 由来.依存更新で壊れやすいもの)
        private static readonly string[] RequiredAssemblies = {
            "OpenCvSharp",
            "OpenCvSharp.Extensions",
            "System.Memory",
            "System.Buffers",
            "System.Runtime.CompilerServices.Unsafe",
            "System.Numerics.Vectors",
            "System.Drawing.Common",
        };

        public static bool IsRequested(string[] args) {
            return HasFlag(args, FlagSelfTest);
        }

        /// <summary>
        /// セルフテストを実行して終了コードを返す(UI スレッド=STA のメインスレッドから呼ぶこと).
        /// </summary>
        public static int Run(string[] args) {
            var baseDir = AppDomain.CurrentDomain.BaseDirectory;
            var stamp = $"{DateTime.Now:yyyyMMdd-HHmmss}-{Process.GetCurrentProcess().Id}";
            var tempRoot = Path.Combine(Path.GetTempPath(), "TIASshot-selftest");
            var workDir = Path.Combine(tempRoot, $"work-{stamp}");
            var outPath = GetArgValue(args, FlagOut) ?? Path.Combine(tempRoot, $"result-{stamp}.txt");
            var keep = HasFlag(args, FlagKeep);

            SelfTestReport report;
            try {
                // 不正な --selftest-out(空文字・不正文字等)も結果ファイルを作成できない場合と同じく異常(2)とする
                outPath = Path.GetFullPath(outPath);
                report = new SelfTestReport(outPath);
            } catch (Exception ex) {
                Debug.WriteLine($"セルフテスト結果ファイルを作成できません: {outPath}: {ex}");
                return ExitError;
            }

            using (report)
            using (var done = new ManualResetEventSlim(false)) {
                StartWatchdog(report, done);
                AppDomain.CurrentDomain.UnhandledException += (s, e) => {
                    report.WriteLine($"[FAIL] unhandled: 未処理例外でプロセスが終了します: {SelfTestReport.DescribeException(e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString()))}");
                    report.WriteLine("RESULT: FAIL (unhandled exception)");
                    Environment.Exit(ExitError);
                };

                var resolveLog = new AssemblyResolveLog();
                resolveLog.Attach();

                report.WriteLine("TIASshot selftest");
                report.WriteLine($"  exe: {Assembly.GetExecutingAssembly().Location}");
                report.WriteLine($"  version: {Assembly.GetExecutingAssembly().GetName().Version}");
                report.WriteLine($"  build: {BuildDescription()}");
                report.WriteLine($"  process: {(Environment.Is64BitProcess ? "x64" : "x86")} / CLR {Environment.Version}");
                report.WriteLine($"  started: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
                report.WriteLine($"  work dir: {workDir}");
                report.WriteLine($"  result file: {outPath}");
                report.WriteLine();

                int exitCode;
                try {
                    // WinForms の UI 例外をダイアログ表示ではなく例外として呼び出し元(DoEvents)へ伝播させる
                    Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException);

                    var checks = new List<SelfTestCheck>();
                    var ctx = new Context { BaseDir = baseDir, WorkDir = workDir };

                    checks.Add(RunCheck(report, resolveLog, "deps", "依存アセンブリのロードと OpenCvSharp の最小実行",
                        c => CheckDependencies(c, ctx, resolveLog)));

                    string setupError = PrepareConfig(ctx);

                    checks.Add(RunCheck(report, resolveLog, "preview", "偽カメラ → ShowImage(FastClone)→ 表示用 Bitmap のプレビュー経路",
                        c => {
                            if (setupError != null) { c.Fail(setupError); return; }
                            CheckPreview(c);
                        }));

                    checks.Add(RunCheck(report, resolveLog, "calib-save", "合成チャートでチャート検出 → 色変換行列算出 → 撮影 → sRGB 変換画像の保存",
                        c => {
                            if (setupError != null) { c.Fail(setupError); return; }
                            CheckCalibrationAndSave(c, ctx);
                        }));

                    var failed = checks.Where(x => x.Status == SelfTestStatus.Fail).ToList();
                    report.WriteLine();
                    if (failed.Count == 0) {
                        report.WriteLine($"RESULT: PASS ({checks.Count}/{checks.Count})");
                        exitCode = ExitPass;
                    } else {
                        report.WriteLine($"RESULT: FAIL ({failed.Count}/{checks.Count} failed: {string.Join(", ", failed.Select(x => x.Id))})");
                        exitCode = ExitFail;
                    }
                } catch (Exception ex) {
                    report.WriteLine($"[FAIL] runner: セルフテスト自体が異常終了しました: {SelfTestReport.DescribeException(ex)}");
                    report.WriteLine("RESULT: FAIL (runner error)");
                    exitCode = ExitError;
                }

                CleanupWorkDir(report, workDir, keep || exitCode != ExitPass);
                done.Set();
                return exitCode;
            }
        }

        // ================================================================================
        // 共通
        // ================================================================================

        private sealed class Context {
            public string BaseDir;
            public string WorkDir;
            public string SaveRoot;         // 一時設定の File/SaveFolder
            public string UserSaveFolder;   // 元の config.xml の File/SaveFolder(書き込まないことの確認用)
        }

        private static string BuildDescription() {
            var flags = new List<string>();
#if DEBUG
            flags.Add("DEBUG");
#endif
#if MEMMONITOR
            flags.Add("MEMMONITOR");
#endif
#if WHITEBOARD
            flags.Add("WHITEBOARD");
#endif
            flags.Add("SELFTEST");
            return string.Join(" ", flags);
        }

        private static bool HasFlag(string[] args, string name) {
            return args.Any(a => string.Equals(a, name, StringComparison.OrdinalIgnoreCase));
        }

        /// <summary>
        /// name の直後の引数を値として返す.name が無い・末尾にある・直後が別フラグ("--" 始まり)の場合は null(値の欠落扱い).
        /// </summary>
        private static string GetArgValue(string[] args, string name) {
            for (var i = 0; i < args.Length - 1; i++) {
                if (!string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) continue;
                var value = args[i + 1];
                return value.StartsWith("--", StringComparison.Ordinal) ? null : value;
            }
            return null;
        }

        private static void StartWatchdog(SelfTestReport report, ManualResetEventSlim done) {
            var watchdog = new Thread(() => {
                if (done.Wait(OverallTimeout)) return;
                report.WriteLine($"[FAIL] watchdog: {OverallTimeout.TotalSeconds:0} 秒以内に終了しませんでした(ハングの疑い.Invoke の相互待機・モーダル表示等)");
                report.WriteLine("RESULT: FAIL (timeout)");
                Environment.Exit(ExitTimeout);
            }) {
                IsBackground = true,
                Name = "SelfTest.Watchdog",
            };
            watchdog.Start();
        }

        /// <summary>
        /// 1 項目を実行し,想定外の例外は FAIL として記録する.項目の実行中に解決できなかった
        /// アプリ依存アセンブリ(AssemblyResolve)があれば,その項目の FAIL として記録する.
        /// </summary>
        private static SelfTestCheck RunCheck(SelfTestReport report, AssemblyResolveLog resolveLog,
                string id, string title, Action<SelfTestCheck> body) {
            var check = new SelfTestCheck(id, title);
            var resolveStart = resolveLog.Count;
            var sw = Stopwatch.StartNew();
            try {
                body(check);
            } catch (Exception ex) {
                check.FailException("想定外の例外", ex);
            }
            var ignorable = 0;
            foreach (var entry in resolveLog.Since(resolveStart)) {
                if (entry.IsIgnorable) {
                    ignorable++;    // サテライトリソース等の探索(見つからないのが通常)
                } else if (IsCameraSdkAssembly(entry.Name)) {
                    check.Warn($"AssemblyResolve(カメラ SDK・判定対象外): {entry}");
                } else {
                    check.Fail($"アセンブリを解決できませんでした(AssemblyResolve): {entry}");
                }
            }
            if (ignorable > 0) check.Info($"リソース系の AssemblyResolve {ignorable} 件(サテライトリソースの探索.判定対象外)");
            check.Info($"所要時間 {sw.ElapsedMilliseconds} ms");
            report.WriteCheck(check);
            return check;
        }

        /// <summary>検査の 1 ステップを実行し,例外は FAIL として記録して次のステップへ進む</summary>
        private static bool Step(SelfTestCheck c, string name, Action action) {
            try {
                action();
                return true;
            } catch (Exception ex) {
                c.FailException(name, ex);
                return false;
            }
        }

        private static bool IsCameraSdkAssembly(string simpleName) {
            if (simpleName == null) return false;
            return CameraSdkAssemblyPrefixes.Any(p => simpleName.StartsWith(p, StringComparison.OrdinalIgnoreCase));
        }

        /// <summary>
        /// セルフテスト用の一時設定を作り,Config に読み込む.同梱 config.xml を複製し,
        /// 保存先(File/SaveFolder)を作業フォルダに,参照 CSV を実行ファイル基準の絶対パスに,
        /// 効果音を存在しないパス(再生しない)に差し替える.元の config.xml は変更しない.
        /// </summary>
        /// <returns>失敗時はその理由,成功時は null</returns>
        private static string PrepareConfig(Context ctx) {
            try {
                // 一時設定の作成・読み込みは層②のテストハーネスと共通(SelfTestConfig)
                var prepared = SelfTestConfig.Prepare(Path.Combine(ctx.BaseDir, "config.xml"), ctx.BaseDir, ctx.WorkDir);
                ctx.UserSaveFolder = prepared.UserSaveFolder;
                ctx.SaveRoot = prepared.SaveRoot;
                if (!Config.IsLoaded()) return $"一時設定ファイルを読み込めません: {prepared.ConfigPath}";
                return null;
            } catch (Exception ex) {
                return $"一時設定の準備に失敗しました: {SelfTestReport.DescribeException(ex)}";
            }
        }

        private static void CleanupWorkDir(SelfTestReport report, string workDir, bool keep) {
            if (!Directory.Exists(workDir)) return;
            if (keep) {
                report.WriteLine($"work dir kept: {workDir}");
                return;
            }
            try {
                Directory.Delete(workDir, true);
            } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) {
                report.WriteLine($"WARN: 作業フォルダを削除できませんでした(手動で削除してください): {workDir}: {ex.Message}");
            }
        }

        private static bool IsUnder(string path, string root) {
            if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(root)) return false;
            var p = Path.GetFullPath(path).TrimEnd('\\') + "\\";
            var r = Path.GetFullPath(root).TrimEnd('\\') + "\\";
            return p.StartsWith(r, StringComparison.OrdinalIgnoreCase);
        }

        // ================================================================================
        // 項目 1: 依存アセンブリ
        // ================================================================================

        private static void CheckDependencies(SelfTestCheck c, Context ctx, AssemblyResolveLog resolveLog) {
            var loaded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            Step(c, "実行時設定ファイル(TIASshot.exe.config)", () => {
                var cfg = AppDomain.CurrentDomain.SetupInformation.ConfigurationFile;
                if (!File.Exists(cfg)) {
                    c.Fail($"実行時設定ファイルがありません(バインディングリダイレクトが効かない): {cfg}");
                    return;
                }
                CheckBindingRedirects(c, ctx.BaseDir, cfg, loaded);
            });

            Step(c, "参照アセンブリの推移的ロード", () => CheckReferenceClosure(c, ctx.BaseDir, loaded));
            Step(c, "bin 内の管理アセンブリのロード", () => CheckBinAssemblies(c, ctx.BaseDir, loaded));

            foreach (var name in RequiredAssemblies) {
                c.Expect(loaded.Contains(name), $"必須の依存 {name} がロードされていません(bin に無い/参照が外れた可能性)");
            }

            Step(c, "OpenCvSharp の最小実行(ネイティブ DLL・Mat 演算・System.Memory 経由のアクセス)", () => CheckOpenCvRuntime(c));
            Step(c, "BitmapConverter(Mat↔Bitmap)と FastClone", () => CheckBitmapConverter(c));
            Step(c, "同梱の設定・参照ファイル", () => CheckAppFiles(c, ctx.BaseDir));
            Step(c, "Form1 の実コンストラクタ(InitializeComponent)", () => CheckRealForm1Constructor(c));

            var assemblies = AppDomain.CurrentDomain.GetAssemblies()
                .Where(a => !a.IsDynamic && IsUnder(a.Location, ctx.BaseDir))
                .Select(a => $"{a.GetName().Name} {a.GetName().Version}")
                .OrderBy(x => x);
            c.Info($"bin からロード済み: {string.Join(", ", assemblies)}");
        }

        /// <summary>
        /// バインディングリダイレクトの newVersion と bin の DLL のバージョンが一致し,
        /// リダイレクト先の完全名でロードできることを確認する(依存更新でリダイレクトがずれると起動時に FileLoadException).
        /// </summary>
        private static void CheckBindingRedirects(SelfTestCheck c, string baseDir, string configFile, HashSet<string> loaded) {
            var doc = new XmlDocument();
            doc.Load(configFile);
            var ns = new XmlNamespaceManager(doc.NameTable);
            ns.AddNamespace("b", "urn:schemas-microsoft-com:asm.v1");
            var count = 0;
            foreach (XmlElement da in doc.SelectNodes("//b:dependentAssembly", ns)) {
                var id = da.SelectSingleNode("b:assemblyIdentity", ns) as XmlElement;
                var br = da.SelectSingleNode("b:bindingRedirect", ns) as XmlElement;
                if (id == null || br == null) continue;
                count++;
                var name = id.GetAttribute("name");
                var newVersion = br.GetAttribute("newVersion");
                var token = id.GetAttribute("publicKeyToken");
                var culture = string.IsNullOrEmpty(id.GetAttribute("culture")) ? "neutral" : id.GetAttribute("culture");
                var dll = Path.Combine(baseDir, name + ".dll");
                if (!File.Exists(dll)) {
                    // bin に無い=フレームワーク提供か未使用(例: System.ValueTuple は net471 以降 mscorlib に統合).
                    // 実際に参照されていれば推移的ロードで検査されるため,ここでは強制ロードしない
                    // (強制ロードすると不要な AssemblyResolve を自ら発生させる)
                    c.Info($"redirect {name} → {newVersion}: bin に DLL なし(フレームワーク提供または未使用.参照されていれば推移的ロードで検査)");
                    continue;
                }
                var binVersion = AssemblyName.GetAssemblyName(dll).Version;
                if (binVersion != new Version(newVersion)) {
                    c.Fail($"リダイレクト不一致: {name} は newVersion={newVersion} だが bin の DLL は {binVersion}(App.config の bindingRedirect を更新する必要がある)");
                    continue;
                }
                try {
                    Assembly.Load(new AssemblyName($"{name}, Version={newVersion}, Culture={culture}, PublicKeyToken={token}"));
                    loaded.Add(name);
                    c.Info($"redirect {name} → {newVersion}: bin の DLL と一致・ロード OK");
                } catch (Exception ex) {
                    c.FailException($"redirect {name} → {newVersion} のロード失敗", ex);
                }
            }
            c.Info($"バインディングリダイレクト {count} 件を確認");
        }

        /// <summary>
        /// 本体アセンブリの参照を推移的に辿って実際にロードする(bin 由来のアセンブリのみ再帰).
        /// 実行時に初めて必要になる依存(例: OpenCvSharp → System.Memory → System.Runtime.CompilerServices.Unsafe)を起動前に解決させる.
        /// </summary>
        private static void CheckReferenceClosure(SelfTestCheck c, string baseDir, HashSet<string> loaded) {
            var root = typeof(SelfTestRunner).Assembly;
            var queue = new Queue<KeyValuePair<AssemblyName, string>>();
            foreach (var r in root.GetReferencedAssemblies()) queue.Enqueue(new KeyValuePair<AssemblyName, string>(r, root.GetName().Name));
            var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            var framework = 0;
            while (queue.Count > 0) {
                var item = queue.Dequeue();
                var an = item.Key;
                if (!seen.Add(an.Name)) continue;
                Assembly asm;
                try {
                    asm = Assembly.Load(an);
                } catch (Exception ex) {
                    var msg = $"{an.FullName}(参照元 {item.Value})のロード失敗";
                    if (IsCameraSdkAssembly(an.Name)) {
                        c.Warn($"{msg}(カメラ SDK・判定対象外): {SelfTestReport.DescribeException(ex)}");
                    } else {
                        c.FailException(msg, ex);
                    }
                    continue;
                }
                loaded.Add(an.Name);
                if (!IsUnder(asm.Location, baseDir)) {
                    framework++;
                    continue;
                }
                var actual = asm.GetName().Version;
                c.Info($"{an.Name} {actual}(参照 {an.Version},参照元 {item.Value}): bin からロード");
                if (IsCameraSdkAssembly(an.Name)) continue;     // SDK の内部依存までは辿らない
                foreach (var r in asm.GetReferencedAssemblies()) {
                    queue.Enqueue(new KeyValuePair<AssemblyName, string>(r, an.Name));
                }
            }
            c.Info($"参照を辿ってロードしたアセンブリ {seen.Count} 件(うち GAC/Framework {framework} 件)");
        }

        /// <summary>bin 直下の管理アセンブリ(DLL)がすべてロードできることを確認する(ネイティブ DLL は対象外)</summary>
        private static void CheckBinAssemblies(SelfTestCheck c, string baseDir, HashSet<string> loaded) {
            var managed = 0;
            foreach (var dll in Directory.GetFiles(baseDir, "*.dll")) {
                AssemblyName an;
                try {
                    an = AssemblyName.GetAssemblyName(dll);
                } catch (BadImageFormatException) {
                    continue;   // ネイティブ DLL(管理アセンブリでない)
                }
                managed++;
                if (loaded.Contains(an.Name)) continue;
                try {
                    Assembly.Load(an);
                    loaded.Add(an.Name);
                    c.Info($"{an.Name} {an.Version}: bin からロード(本体から直接は参照されない)");
                } catch (Exception ex) {
                    if (IsCameraSdkAssembly(an.Name)) {
                        c.Warn($"{an.FullName} のロード失敗(カメラ SDK・判定対象外): {SelfTestReport.DescribeException(ex)}");
                    } else {
                        c.FailException($"bin の {Path.GetFileName(dll)}({an.FullName})のロード失敗", ex);
                    }
                }
            }
            c.Info($"bin 直下の管理アセンブリ {managed} 件を確認");
        }

        private static void CheckOpenCvRuntime(SelfTestCheck c) {
            c.Info($"OpenCV {Cv2.GetVersionString()}");

            using (var src = new Mat(48, 64, MatType.CV_8UC3, new Scalar(10, 20, 30)))
            using (var blurred = new Mat()) {
                Cv2.GaussianBlur(src, blurred, new OpenCvSharp.Size(5, 5), 0);
                var mean = Cv2.Mean(blurred);
                c.Expect(Math.Abs(mean.Val0 - 10) < 0.5 && Math.Abs(mean.Val1 - 20) < 0.5 && Math.Abs(mean.Val2 - 30) < 0.5,
                    $"GaussianBlur/Mean の結果が不正: {mean}");

                // System.Memory(Span)経由の画素アクセス
                var span = src.AsSpan<byte>();
                long sum = 0;
                for (var i = 0; i < span.Length; i++) sum += span[i];
                c.Expect(sum == 48L * 64 * (10 + 20 + 30), $"Mat.AsSpan の画素合計が不正: {sum}");
            }

            // 行列演算(CV_64F): A * A^-1 ≈ I
            using (var a = new Mat(3, 3, MatType.CV_64FC1)) {
                var vals = new double[] { 4, 1, 2, 0, 3, 1, 1, 0, 2 };
                for (var i = 0; i < 9; i++) a.At<double>(i / 3, i % 3) = vals[i];
                // MatExpr もネイティブ資源を持つため,ToMat の元の式も using で解放する
                using (var invExpr = a.Inv())
                using (var inv = invExpr.ToMat())
                using (var prodExpr = a * inv)
                using (var prod = prodExpr.ToMat())
                using (var eyeExpr = Mat.Eye(3, 3, MatType.CV_64FC1))
                using (var eye = eyeExpr.ToMat()) {
                    var err = Cv2.Norm(prod, eye, NormTypes.L2);
                    c.Expect(err < 1e-9, $"行列演算(A*A^-1)の誤差が大きい: {err}");
                }
            }

            using (var dict = CvAruco.GetPredefinedDictionary(PredefinedDictionaryType.Dict4X4_50)) {
                c.Expect(dict.MarkerSize == 4, $"ArUco 辞書のマーカーサイズが不正: {dict.MarkerSize}");
            }
            c.Info("Mat 演算・行列演算・Span アクセス・ArUco 辞書: OK");
        }

        private static void CheckBitmapConverter(SelfTestCheck c) {
            using (var src = SelfTestChart.CreatePlainFrame(3, 64, 48)) {
                Cv2.Circle(src, new OpenCvSharp.Point(20, 20), 8, new Scalar(0, 255, 0), -1);
                using (var bmp = src.ToBitmap())
                using (var clone = BitmapUtil.FastClone(bmp))
                using (var back = clone.ToMat()) {
                    c.Expect(bmp.Width == 64 && bmp.Height == 48, $"ToBitmap のサイズが不正: {bmp.Width}x{bmp.Height}");
                    c.Expect(!ReferenceEquals(bmp, clone), "FastClone が同一インスタンスを返した");
                    var diff = Cv2.Norm(src, back, NormTypes.L1);
                    c.Expect(diff == 0, $"Mat → Bitmap → FastClone → Mat の往復で画素が変化: L1={diff}");
                }
            }
            c.Info("Mat → Bitmap → FastClone → Mat の往復: 画素一致");
        }

        private static void CheckAppFiles(SelfTestCheck c, string baseDir) {
            var configPath = Path.Combine(baseDir, "config.xml");
            if (!c.Expect(File.Exists(configPath), $"config.xml がありません: {configPath}")) return;
            Config.Load(configPath);
            c.Expect(Config.IsLoaded(), "config.xml を読み込めません(ルート要素 Config が無い)");
            foreach (var key in new[] { "File/TccSrgbRef", "File/TccXyzRef" }) {
                var path = Path.Combine(baseDir, Config.GetString(key));
                using (var m = IoUtil.LoadMatFromCsv(path)) {
                    c.Expect(m != null && m.Rows == 24 && m.Cols == 3,
                        $"{key}({path})を 24x3 として読み込めません");
                }
            }
            c.Info("config.xml・tcc_srgb.csv・tcc_xyz.csv: OK");
        }

        /// <summary>
        /// 通常起動と同じ Form1 コンストラクタ(InitializeComponent.IC Imaging Control のコントロール生成を含む)を実行する.
        /// Form1_Load(カメラ接続)は表示しないため発火しない.カメラ SDK 起因の失敗は判定対象外(WARN)とする.
        /// </summary>
        private static void CheckRealForm1Constructor(SelfTestCheck c) {
            try {
                using (var form = new Form1()) {
                    c.Info($"Form1 コンストラクタ: OK({form.Text})");
                }
            } catch (Exception ex) {
                if (IsCameraSdkRelated(ex)) {
                    c.Warn($"Form1 コンストラクタがカメラ SDK 起因で失敗(SDK 未導入環境では想定内): {SelfTestReport.DescribeException(ex)}");
                } else {
                    c.FailException("Form1 コンストラクタの失敗", ex);
                }
            }
        }

        private static bool IsCameraSdkRelated(Exception ex) {
            for (var e = ex; e != null; e = e.InnerException) {
                var file = (e as FileNotFoundException)?.FileName ?? (e as FileLoadException)?.FileName;
                if (file != null && IsCameraSdkAssembly(new AssemblyName(file).Name)) return true;
                if ((e.StackTrace ?? "").Contains("TIS.Imaging")) return true;
            }
            return false;
        }

        // ================================================================================
        // 項目 2: プレビュー経路
        // ================================================================================

        private static void CheckPreview(SelfTestCheck c) {
            const int frames = 12;
            const int width = 640;
            const int height = 480;

            Form1 form = null;
            PreviewMonitor monitor = null;
            SelfTestCamera camera = null;
            try {
                form = new Form1(Form1.SelfTestHeadless.Instance);
                monitor = new PreviewMonitor();                          // 実コンストラクタ(InitializeComponent を含む)
                monitor.SelfTestForceShown(new System.Drawing.Size(1280, 800));  // 副ディスプレイ有りを模す
                form.SelfTestStart(monitor);

                camera = new SelfTestCamera(form, i => SelfTestChart.CreatePlainFrame(i, width, height));
                if (!c.Expect(camera.Connect(), $"偽カメラの接続(CameraBase.BootCheck)に失敗: {camera.ErrorMsg}",
                        "偽カメラ接続(CameraBase コンストラクタ・BootCheck): OK")) return;

                camera.StartPreview(frames, 30, false);
                var finished = SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, PreviewTimeout);
                if (!c.Expect(finished, $"プレビューが {PreviewTimeout.TotalSeconds:0} 秒以内に終わりません({camera.FramesDelivered}/{frames} フレーム)")) return;
                if (camera.PreviewError != null) c.FailException("プレビューコールバックの例外", camera.PreviewError);
                c.Expect(camera.FramesDelivered == frames, $"コールバックへ渡したフレーム数が不正: {camera.FramesDelivered}/{frames}",
                    $"コールバック → ProcessCalibrationFrame → Form1.ShowImage: {frames} フレーム");

                // 本モニタはタイマー(約 33ms)駆動.最終フレームが反映されるまでメッセージを回す
                SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(300));
                monitor.SelfTestRefresh();

                // 入力 Bitmap(カメラ側の二重バッファ)をすべて解放してから表示側を検査する.
                // 表示側が入力と独立したコピー(FastClone)を持っていれば,解放後も読める.
                camera.Disconnect();
                var expected = SelfTestChart.PlainFrameColor(frames - 1);
                CheckDisplayedImage(c, "本モニタ picDisplay(MainRefreshTimer_Tick)", form.SelfTestDisplayedImage, camera, width, height, expected);
                CheckDisplayedImage(c, "副モニタ PreviewMonitor(UpdateImage → RefreshTimer_Tick)", monitor.SelfTestDisplayedImage, camera, width, height, expected);

                c.Expect(form.SelfTestLastError == null, $"エラー通知(ShowError)が発生: {form.SelfTestLastError}");
            } finally {
                ShutdownCamera(camera);
                form?.SelfTestShutdown();
                form?.Dispose();
                monitor?.SelfTestShutdown();
                monitor?.Dispose();
            }
        }

        private static void CheckDisplayedImage(SelfTestCheck c, string label, Image image, SelfTestCamera camera,
                int width, int height, Scalar expectedBgr) {
            if (!c.Expect(image != null, $"{label}: 表示用 Bitmap が設定されていません")) return;
            c.Expect(!camera.IsProducedBitmap(image), $"{label}: 表示用 Bitmap がカメラの入力 Bitmap そのもの(UI 所有コピーになっていない)");
            var bmp = image as Bitmap;
            if (!c.Expect(bmp != null, $"{label}: 表示画像が Bitmap ではありません({image.GetType().Name})")) return;
            try {
                c.Expect(bmp.Width == width && bmp.Height == height, $"{label}: サイズが不正 {bmp.Width}x{bmp.Height}(期待 {width}x{height})");
                var px = bmp.GetPixel(width / 2, height / 2);
                var ok = px.B == (int)expectedBgr.Val0 && px.G == (int)expectedBgr.Val1 && px.R == (int)expectedBgr.Val2;
                c.Expect(ok, $"{label}: 画素値が最終フレームと一致しない(R{px.R} G{px.G} B{px.B},期待 R{expectedBgr.Val2} G{expectedBgr.Val1} B{expectedBgr.Val0})",
                    $"{label}: 最終フレームの UI 所有コピーを表示(入力 Bitmap 解放後も有効)");
            } catch (ArgumentException ex) {
                // 解放済み Bitmap へのアクセスは ArgumentException(パラメーターが無効)になる
                c.FailException($"{label}: 表示用 Bitmap が入力の解放とともに無効になった(所有権モデル違反の疑い)", ex);
            }
        }

        private static void ShutdownCamera(SelfTestCamera camera) {
            if (camera == null) return;
            camera.RequestStopPreview();
            // プレビュースレッドは Invoke で UI スレッドを待つことがあるため,Join ではなくメッセージを回して待つ
            SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, TimeSpan.FromSeconds(10));
            SelfTestPump.PumpUntil(() => !camera.IsSaveThreadStarted, TimeSpan.FromSeconds(10));
            camera.Disconnect();
        }

        // ================================================================================
        // 項目 3: 校正〜保存
        // ================================================================================

        private static void CheckCalibrationAndSave(SelfTestCheck c, Context ctx) {
            c.Info($"保存先を一時フォルダに差し替え: {ctx.SaveRoot}(config.xml の {ctx.UserSaveFolder} には書き込まない)");

            Mat tccSrgb = null;
            Mat chart = null;
            Form1 form = null;
            SelfTestCamera camera = null;
            try {
                tccSrgb = IoUtil.LoadMatFromCsv(Config.GetString("File/TccSrgbRef"));
                if (!c.Expect(tccSrgb != null, "tcc_srgb.csv を読み込めません")) return;
                chart = SelfTestChart.CreateChartFrame(tccSrgb);
                var chartForFactory = chart;

                form = new Form1(Form1.SelfTestHeadless.Instance);
                form.SelfTestStart(null);
                camera = new SelfTestCamera(form, i => chartForFactory.Clone());
                if (!c.Expect(camera.Connect(), $"偽カメラの接続に失敗: {camera.ErrorMsg}")) return;

                // --- 自動校正(チャート検出 → 固定判定 → ホワイトバランス → 変換行列算出) ---
                var maxFrames = Config.GetInt("Calib/ChartSetCount") + Config.GetInt("Calib/Frames") + 60;
                camera.StartPreview(maxFrames, 0, true);
                var finished = SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, CalibrationTimeout);
                if (!c.Expect(finished, $"校正が {CalibrationTimeout.TotalSeconds:0} 秒以内に終わりません({camera.FramesDelivered} フレーム)")) return;
                if (camera.PreviewError != null) {
                    c.FailException("プレビュー(校正)中の例外", camera.PreviewError);
                    return;
                }
                if (!c.Expect(camera.IsCalibrated,
                        $"校正が完了しません({camera.FramesDelivered} フレーム.最後の表示: 「{form.SelfTestMessage}」.チャート検出の失敗が疑われる)")) return;
                SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(100));    // CalcTcc の ShowMessage / EnableShots(Invoke)を処理させる
                c.Info($"チャート検出・校正完了: {camera.FramesDelivered} フレーム,疑似ゲイン {camera.DescribeGain()},表示「{form.SelfTestMessage}」");
                c.Expect(form.SelfTestShotsEnabled, "校正完了後に撮影ボタンが有効化されていません(CalcTcc → EnableShots)");

                var white = camera.MeasureWhitePatch();
                var refB = Config.GetFloat("Calib/Reference/B");
                var refG = Config.GetFloat("Calib/Reference/G");
                var refR = Config.GetFloat("Calib/Reference/R");
                c.Expect(Math.Abs(white.Val0 - refB) < 10 && Math.Abs(white.Val1 - refG) < 10 && Math.Abs(white.Val2 - refR) < 10,
                    $"白色票がホワイトバランス目標に収束していません: B{white.Val0:0.0} G{white.Val1:0.0} R{white.Val2:0.0}(目標 {refB}/{refG}/{refR})",
                    $"白色票(13 番)の計測値: B{white.Val0:0.0} G{white.Val1:0.0} R{white.Val2:0.0}(目標 {refB}/{refG}/{refR})");

                var channels = GetConversionChannels();
                var frameSize = new OpenCvSharp.Size(SelfTestChart.FrameWidth, SelfTestChart.FrameHeight);
                CheckCalibrationOutputs(c, ctx, camera.CalibrationFolder, channels, frameSize, tccSrgb);

                // --- 撮影 → 保存(ShotOne → RunShotLoop → SaveThread → SaveImages) ---
                var shotOk = Step(c, "撮影(CameraBase.ShotOne)", () => camera.ShotOne());
                var saved = SelfTestPump.PumpUntil(() => !camera.IsSaveThreadStarted, SaveTimeout);
                if (!c.Expect(saved, $"画像保存が {SaveTimeout.TotalSeconds:0} 秒以内に終わりません")) return;
                SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(100));    // SaveThread の ShowMessage / EnableShots(Invoke)を処理させる
                if (!shotOk) return;
                c.Expect(form.SelfTestLastError == null, $"エラー通知(ShowError)が発生: {form.SelfTestLastError}");
                c.Expect(form.SelfTestShotsEnabled, "保存完了後に撮影ボタンが再有効化されていません(SaveThread → EnableShots(true))");
                c.Info($"撮影・保存完了: 表示「{form.SelfTestMessage}」");
                CheckShotOutputs(c, ctx, camera.CurrentSaveFolder, camera.CalibrationFolder, channels, frameSize, tccSrgb);
            } finally {
                ShutdownCamera(camera);
                form?.SelfTestShutdown();
                form?.Dispose();
                chart?.Dispose();
                tccSrgb?.Dispose();
            }
        }

        /// <summary>ColorCorrector と同じ規則で変換行列のチャネル数一覧を得る(4〜17 の範囲・重複除去・昇順)</summary>
        private static int[] GetConversionChannels() {
            return Config.GetString("Calib/ConversionChannels")
                .Split(',')
                .Select(x => int.Parse(x.Trim()))
                .Where(x => x >= 4 && x <= 17)
                .Distinct()
                .OrderBy(x => x)
                .ToArray();
        }

        private static string ChannelFile(string key, int channel, int no = 1) {
            return Config.GetString(key).Replace("{CN}", $"{channel:00}").Replace("{NO}", $"{no:0000}");
        }

        private static void CheckCalibrationOutputs(SelfTestCheck c, Context ctx, string folder, int[] channels,
                OpenCvSharp.Size frameSize, Mat tccSrgb) {
            if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"校正データのフォルダがありません: {folder}")) return;
            c.Expect(IsUnder(folder, ctx.SaveRoot), $"校正データが一時フォルダの外に保存されました: {folder}");

            CheckImageFile(c, Path.Combine(folder, Config.GetString("File/TccImage")), frameSize);
            CheckImageFile(c, Path.Combine(folder, Config.GetString("File/TccRoisImage")), frameSize);
            CheckCsvMatrix(c, Path.Combine(folder, Config.GetString("File/TccRgbValues")), 24, 3);

            foreach (var ch in channels) {
                CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2Srgb", ch)), ch, 3);
                CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2Xyz", ch)), ch, 3);
                CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvSrgb2Xyz", ch)), ch, 3);
                if (ch == 17) CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2XyzOld", ch)), ch, 3);

                // 校正チャートを変換した色票値が参照値に戻っているか(合成チャートの歪みは 1 次式なので戻るはず)
                var convertedFile = ChannelFile("File/TccSrgbValues", ch);
                using (var converted = IoUtil.LoadMatFromCsv(Path.Combine(folder, convertedFile))) {
                    if (!c.Expect(converted != null && converted.Rows == 24 && converted.Cols == 3,
                            $"{convertedFile} を 24x3 として読み込めません")) continue;
                    var maxErr = 0.0;
                    for (var i = 0; i < 24; i++) {
                        for (var j = 0; j < 3; j++) {
                            maxErr = Math.Max(maxErr, Math.Abs(converted.At<double>(i, j) - tccSrgb.At<double>(i, j)));
                        }
                    }
                    c.Expect(maxErr < 5.0, $"{ch} 次元: 色票の変換値が参照値から離れすぎ(最大誤差 {maxErr:0.00})",
                        $"{ch} 次元: 変換行列(有限値)と色票の変換値(参照値との最大誤差 {maxErr:0.00})");
                }
            }
        }

        private static void CheckShotOutputs(SelfTestCheck c, Context ctx, string folder, string calibFolder, int[] channels,
                OpenCvSharp.Size frameSize, Mat tccSrgb) {
            if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"撮影データのフォルダがありません: {folder}")) return;
            c.Expect(IsUnder(folder, ctx.SaveRoot), $"撮影データが一時フォルダの外に保存されました: {folder}");
            c.Expect(!string.Equals(folder, calibFolder, StringComparison.OrdinalIgnoreCase), "撮影データが校正データと同じフォルダに保存されました");

            var info = Path.Combine(folder, Config.GetString("File/Info"));
            if (c.Expect(File.Exists(info), $"撮影情報がありません: {info}")) {
                var text = File.ReadAllText(info);
                c.Expect(text.Contains("Shot,Time(ms),Note"), $"撮影情報の書式が不正: {info}");
            }
            CheckImageFile(c, Path.Combine(folder, ChannelFile("File/RgbImage", 0)), frameSize);

            foreach (var ch in channels) {
                var path = Path.Combine(folder, ChannelFile("File/SrgbImage", ch));
                if (!CheckImageFile(c, path, frameSize)) continue;
                // 変換画像上の色票が参照値(tcc_srgb)に戻っているか(JPEG 圧縮の誤差を許容)
                using (var img = Cv2.ImRead(path, ImreadModes.Color)) {
                    double sumErr = 0, maxErr = 0;
                    for (var i = 0; i < 24; i++) {
                        var roi = SelfTestChart.PatchRoi(i);
                        var inner = new Rect(roi.X + 10, roi.Y + 10, roi.Width - 20, roi.Height - 20);
                        using (var patch = new Mat(img, inner)) {
                            var mean = Cv2.Mean(patch);
                            for (var j = 0; j < 3; j++) {
                                var err = Math.Abs(mean[j] - tccSrgb.At<double>(i, j));
                                sumErr += err;
                                maxErr = Math.Max(maxErr, err);
                            }
                        }
                    }
                    var meanErr = sumErr / 72;
                    c.Expect(meanErr < 6.0 && maxErr < 15.0,
                        $"{Path.GetFileName(path)}: 色票が参照値に戻っていない(平均誤差 {meanErr:0.00},最大 {maxErr:0.00})",
                        $"{Path.GetFileName(path)}: 保存・読み戻し OK,色票の参照値との誤差 平均 {meanErr:0.00} / 最大 {maxErr:0.00}");
                }
            }
        }

        private static bool CheckImageFile(SelfTestCheck c, string path, OpenCvSharp.Size expectedSize) {
            if (!c.Expect(File.Exists(path), $"画像がありません: {path}")) return false;
            using (var img = Cv2.ImRead(path, ImreadModes.Color)) {
                if (!c.Expect(!img.Empty(), $"画像を読み戻せません: {path}")) return false;
                return c.Expect(img.Size() == expectedSize, $"画像サイズが不正: {path}({img.Width}x{img.Height},期待 {expectedSize.Width}x{expectedSize.Height})");
            }
        }

        private static void CheckCsvMatrix(SelfTestCheck c, string path, int rows, int cols) {
            if (!c.Expect(File.Exists(path), $"CSV がありません: {path}")) return;
            using (var m = IoUtil.LoadMatFromCsv(path)) {
                if (!c.Expect(m != null, $"CSV を読み戻せません: {path}")) return;
                if (!c.Expect(m.Rows == rows && m.Cols == cols, $"CSV の形状が不正: {path}({m.Rows}x{m.Cols},期待 {rows}x{cols})")) return;
                var allFinite = true;
                var allZero = true;
                for (var i = 0; i < m.Rows; i++) {
                    for (var j = 0; j < m.Cols; j++) {
                        var v = m.At<double>(i, j);
                        if (double.IsNaN(v) || double.IsInfinity(v)) allFinite = false;
                        if (v != 0) allZero = false;
                    }
                }
                c.Expect(allFinite, $"CSV に非有限値(NaN/Infinity)が含まれます: {path}");
                c.Expect(!allZero, $"CSV が全て 0 です: {path}");
            }
        }

        // ================================================================================
        // AssemblyResolve の記録
        // ================================================================================

        /// <summary>
        /// AppDomain.AssemblyResolve(通常のバインドで見つからなかったアセンブリ)を記録する.
        /// 解決はせず null を返す(挙動は変えない).
        /// </summary>
        private sealed class AssemblyResolveLog {
            internal sealed class Entry {
                public string Name;
                public string FullName;
                public string Requester;
                public bool IsIgnorable => Name.EndsWith(".resources", StringComparison.OrdinalIgnoreCase)
                                        || Name.EndsWith(".XmlSerializers", StringComparison.OrdinalIgnoreCase);
                public override string ToString() => $"{FullName}(要求元 {Requester ?? "不明"})";
            }

            private readonly List<Entry> _entries = new List<Entry>();
            private readonly object _lock = new object();

            public int Count {
                get { lock (_lock) return _entries.Count; }
            }

            public void Attach() {
                AppDomain.CurrentDomain.AssemblyResolve += (s, e) => {
                    var name = new AssemblyName(e.Name).Name;
                    lock (_lock) {
                        _entries.Add(new Entry { Name = name, FullName = e.Name, Requester = e.RequestingAssembly?.GetName().Name });
                    }
                    return null;
                };
            }

            public List<Entry> Since(int start) {
                lock (_lock) return _entries.Skip(start).ToList();
            }
        }
    }
}
#endif