Newer
Older
TIASShot / TIASshot / HwTest / HwTestRunner.cs
#if HWTEST
#if !SELFTEST
#error HWTEST は SELFTEST の部品(SelfTestConfig・SelfTestReport・SelfTestPump)を使うため,SELFTEST と同時に定義すること
#endif
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using System.Threading;
using System.Windows.Forms;

namespace TIASshot {

    /// <summary>
    /// 実機テスト(HWTEST 限定).<c>TIASshot.exe --hwtest --camera &lt;lucam|iscam&gt; [--hwtest-out &lt;path&gt;] [--with-chart]</c> で起動すると,
    /// 実際の Form1 を表示し,指定した機種のカメラと光源を操作して次の項目を検査し,終了コードと結果ファイルで結果を返す(PLAN_06).
    /// <list type="number">
    /// <item>connect: 指定した機種に接続でき,機種名・シリアル番号が取れる</item>
    /// <item>preview: プレビューが流れる(フレームレートの下限)</item>
    /// <item>exposure-gain: 露光・ゲインの上げ下げがフレームの平均輝度に期待どおりに効き(露光は比,ゲインは差),元の値(絶対値)へ正確に戻せる</item>
    /// <item>light: 光源の点灯・消灯が平均輝度に効き,ポートを閉じた後に開き直せる</item>
    /// <item>close-preview: プレビュー中に閉じる処理を呼んでもすぐ戻る</item>
    /// <item>reconnect: 切断した後に再接続でき,フレームが届く</item>
    /// <item>cleanup: 後始末(露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる(消灯はしない))と,設定・デバイス状態ファイルを書き換えていないこと</item>
    /// </list>
    /// --with-chart を指定すると,上の preview〜reconnect の代わりにチャートが必要な次の項目を行う(校正は止めない.connect・cleanup は共通).
    /// <list type="number">
    /// <item>calibrate: 実物の舌診チャートで自動校正が完了し,校正後のフレームを変換した色票が参照値(tcc_srgb)に近い</item>
    /// <item>shot: 単発・連続撮影で撮影画像・sRGB 変換画像・Info.csv が保存され,撮影時刻の間隔が設定どおり</item>
    /// <item>close-saving: 保存中に閉じようとすると取り消され,保存後は閉じられる(後始末の順序)</item>
    /// <item>close-calibrating: 再接続して校正中に閉じてもすぐ戻る</item>
    /// </list>
    /// 保存先は一時フォルダ(%TEMP%\TIASshot-hwtest\work-*)に差し替え,同梱 config.xml・デバイス状態ファイル({DeviceName}.xml)・本番の保存先には書き込まない.
    /// 終了コード: 0=全項目合格,1=不合格あり,2=実機テスト自体の異常(引数不正・指定機種が見つからない等),3=タイムアウト.
    /// 【注意】判定のしきい値(HwTestThresholds)は暫定値.結果ファイルには必ず測定値を出す.
    /// </summary>
    internal static partial class HwTestRunner {

        /// <summary>作業フォルダに書き出す一時設定ファイルの名前(セルフテストの config.selftest.xml と区別する)</summary>
        internal const string ConfigFileName = "config.hwtest.xml";

        public static bool IsRequested(string[] args) {
            return HwTestOptions.IsRequested(args);
        }

        /// <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-hwtest");
            var workDir = Path.Combine(tempRoot, $"work-{stamp}");

            var options = HwTestOptions.Parse(args, out var parseError);
            var outPath = options?.OutPath ?? RawArgValue(args, HwTestOptions.FlagOut) ?? Path.Combine(tempRoot, $"result-{stamp}.txt");

            SelfTestReport report;
            try {
                outPath = Path.GetFullPath(outPath);
                report = new SelfTestReport(outPath);
            } catch (Exception ex) {
                Debug.WriteLine($"実機テストの結果ファイルを作成できません: {outPath}: {ex}");
                return HwTestExitCodes.Error;
            }

            using (report) {
                report.WriteLine("TIASshot hwtest");
                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($"  args: {string.Join(" ", args.Skip(1))}");
                report.WriteLine($"  result file: {outPath}");

                if (options == null) {
                    report.WriteLine();
                    report.WriteLine($"[FAIL] args: 引数が不正です: {parseError}");
                    report.WriteLine("  - 使い方: TIASshot.exe --hwtest --camera <lucam|iscam> [--hwtest-out <path>] [--with-chart] [--hwtest-keep]");
                    report.WriteLine("RESULT: FAIL (invalid arguments)");
                    return HwTestExitCodes.Error;
                }
                report.WriteLine($"  camera: {options.Camera}");
                report.WriteLine($"  checks: {string.Join(", ", HwTestJudge.CheckIds(options.WithChart))}"
                    + (options.WithChart ? "(チャートが必要な検査.チャート不要の検査は --with-chart なしで別に実行する)" : ""));
                report.WriteLine($"  work dir: {workDir}");
                report.WriteLine($"  thresholds (暫定): {DescribeThresholds(options.WithChart)}");
                report.WriteLine($"  overall timeout: {HwTestJudge.OverallTimeout(options.WithChart).TotalSeconds:0} 秒");
                var suppressCalibration = HwTestJudge.ShouldSuppressCalibration(options.WithChart);
                report.WriteLine(suppressCalibration
                    ? "  校正: 停止(チャート不要の検査.チャートが写っても校正(チャート検出・ホワイトバランスの調整・変換行列の算出)は走らない)"
                    : "  校正: 有効(チャートが必要な検査.カメラの前に舌診チャートがはまっている前提.校正でカメラのホワイトバランスが変わるが,次の接続でデバイス状態ファイルの値に戻る)");
                report.WriteLine();

                var session = new Session {
                    Report = report,
                    Options = options,
                    BaseDir = baseDir,
                    WorkDir = workDir,
                    CalibrationSuppressed = suppressCalibration,
                    UiThread = Thread.CurrentThread,
                };
                // カメラの接続(コールバック開始)より前に設定する.static のため再接続で作り直したカメラにも効く
                CameraBase.HwTestCalibrationSuppressed = suppressCalibration;
                int exitCode;
                using (var done = new ManualResetEventSlim(false)) {
                    StartWatchdog(session, done);
                    AppDomain.CurrentDomain.UnhandledException += (s, e) => {
                        report.WriteLine($"[FAIL] unhandled: 未処理例外でプロセスが終了します: {SelfTestReport.DescribeException(e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString()))}");
                        EmergencyCleanup(session);
                        report.WriteLine("RESULT: FAIL (unhandled exception)");
                        Environment.Exit(HwTestExitCodes.Error);
                    };

                    try {
                        exitCode = RunChecks(session);
                    } catch (Exception ex) {
                        report.WriteLine($"[FAIL] runner: 実機テスト自体が異常終了しました: {SelfTestReport.DescribeException(ex)}");
                        // 項目の外で失敗した場合も後始末(光源・カメラ)は行う
                        RunCleanupSafely(session);
                        report.WriteLine("RESULT: FAIL (runner error)");
                        exitCode = HwTestExitCodes.Error;
                    }
                    lock (session.StateLock) session.Finished = true;
                    done.Set();
                    CameraBase.HwTestCalibrationSuppressed = false;  // 検査が終わったら元に戻す(カメラは切断済み)
                }

                CleanupWorkDir(report, workDir, HwTestJudge.ShouldKeepWorkDir(options.Keep, exitCode));
                return exitCode;
            }
        }

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

        private sealed class Session {
            public SelfTestReport Report;
            public HwTestOptions Options;
            public bool CalibrationSuppressed;  // 校正を止めているか(チャート不要の検査)
            public string BaseDir;
            public string WorkDir;
            public string SaveRoot;
            public string UserSaveFolder;
            public Thread UiThread;             // Form1 が動くスレッド(Run を呼んだスレッド.タイムアウト時にスタックを取る)

            public Form1 Form;
            public CameraBase Camera;           // 現在のカメラ
            public bool CameraConnected;        // 切断が必要か
            public string FirstSerial;          // 1 回目の接続のシリアル番号(再接続の照合用)
            public bool CameraNotFound;         // 指定機種が見つからない(終了コード 2)

            // 露光・ゲインを変えたまま終わらないための記録(RestorePending の間は後始末で元に戻す)
            public IHwTestAdjustable RestoreTarget;
            public double OriginalExposure;
            public double OriginalGain;
            public double OriginalGainRangeWidth;   // ゲインの読み戻しの絶対誤差の許容に使う(設定範囲の幅)
            public bool RestorePending;
            // 露光・ゲインを変える前の最初の平均輝度(元に戻した後の比較用.参考値)
            public double BaselineAfterRestoreCheck = double.NaN;

            public bool CleanupDone;

            // ウォッチドッグが見る実行中の項目(StateLock で保護)
            public readonly object StateLock = new object();
            public SelfTestCheck CurrentCheck;
            public string CurrentStep;
            public TimeSpan? CurrentLimit;
            public Stopwatch ItemWatch = new Stopwatch();
            public bool Finished;
        }

        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");
            flags.Add("HWTEST");
            return string.Join(" ", flags);
        }

        private static string DescribeThresholds(bool withChart) {
            if (withChart) {
                return $"calibration wait<={HwTestThresholds.CalibrationWait.TotalSeconds:0}s, "
                     + $"color error (8bit, |measured-ref| per channel) mean<={HwTestThresholds.MaxCalibrationMeanError} "
                     + $"max (white patch excluded)<={HwTestThresholds.MaxCalibrationMaxError} white patch max<={HwTestThresholds.MaxCalibrationWhiteMaxError} (白飛びの調査までの暫定) (ΔE76・白飛びの割合は参考), "
                     + $"multi shot {HwTestThresholds.ShotMultiCount}x{HwTestThresholds.ShotMultiIntervalMs}ms interval tolerance<={HwTestThresholds.ShotIntervalToleranceMs}ms, "
                     + $"save wait<={HwTestThresholds.SaveWait.TotalSeconds:0}s, "
                     + $"close-saving shots {HwTestThresholds.CloseSavingShotCount} (retry {HwTestThresholds.CloseSavingRetryShotCount}) x0ms, "
                     + $"calibrating wait<={HwTestThresholds.CalibratingWait.TotalSeconds:0}s, close<={HwTestThresholds.CloseLimit.TotalMilliseconds:0}ms, "
                     + $"frames after close<={HwTestThresholds.MaxFramesAfterClose}";
            }
            return $"preview>={HwTestThresholds.MinPreviewFps}fps, "
                 + $"exposure ratio down<={HwTestThresholds.ExposureLowerMaxRatio} up>={HwTestThresholds.ExposureRaiseMinRatio}, "
                 + $"gain delta>={HwTestThresholds.MinBrightnessDelta} (one side ok), "
                 + $"baseline mid={HwTestThresholds.MidBrightnessLow}-{HwTestThresholds.MidBrightnessHigh} (max {HwTestThresholds.BaselineSearchMaxSteps} steps), "
                 + $"restore rel<={HwTestThresholds.RestoreRelativeTolerance:P0}, "
                 + $"light delta>={HwTestThresholds.MinLightDelta}, close<={HwTestThresholds.CloseLimit.TotalMilliseconds:0}ms, "
                 + $"frames after close<={HwTestThresholds.MaxFramesAfterClose}, reconnect frames>={HwTestThresholds.ReconnectMinFrames}, "
                 + $"dark<={HwTestThresholds.DarkLevel}, saturated pixel>={HwTestThresholds.SaturatedPixelLevel} fraction>{HwTestThresholds.MaxSaturatedFraction:P0}";
        }

        /// <summary>引数の解析に失敗した場合でも結果ファイルの出力先だけは拾う(直後の値.無ければ null)</summary>
        private static string RawArgValue(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 string.IsNullOrWhiteSpace(value) || value.StartsWith("--", StringComparison.Ordinal) ? null : value;
            }
            return null;
        }

        /// <summary>
        /// ウォッチドッグ.実行中の項目が項目の上限を,または全体が全体の上限を超えたら,
        /// その項目の途中までの記録(測定値)と「どのステップで止まったか」を結果ファイルに書き,
        /// 光源のポートを閉じて(消灯はしない)から終了コード 3 で終了する(UI スレッドが SDK 呼び出しで止まっていても動く).
        /// </summary>
        private static void StartWatchdog(Session session, ManualResetEventSlim done) {
            var overall = Stopwatch.StartNew();
            var watchdog = new Thread(() => {
                while (!done.Wait(200)) {
                    string id, step;
                    TimeSpan itemElapsed;
                    TimeSpan? limit;
                    SelfTestCheck check;
                    lock (session.StateLock) {
                        if (session.Finished) return;
                        check = session.CurrentCheck;
                        id = check?.Id;
                        step = session.CurrentStep;
                        itemElapsed = session.ItemWatch.Elapsed;
                        limit = session.CurrentLimit;
                    }
                    var overallLimit = HwTestJudge.OverallTimeout(session.Options.WithChart);
                    var overdue = HwTestJudge.FindOverdue(overall.Elapsed, overallLimit, itemElapsed, limit);
                    if (overdue == HwTestOverdue.None) continue;

                    var report = session.Report;
                    var reason = overdue == HwTestOverdue.Item
                        ? $"項目 {id} が {limit.Value.TotalSeconds:0} 秒以内に終わりません"
                        : $"実機テスト全体が {overallLimit.TotalSeconds:0} 秒以内に終わりません";
                    var message = $"{reason}(実行中のステップ: {step ?? "不明"}.SDK 呼び出しのハング・Invoke の相互待機・モーダル表示の疑い)";
                    // 止まった場所の記録: 結果ファイルを書く前に(状態が動く前に)UI スレッド・保存スレッドのマネージドスタックを取る
                    var stackLines = CaptureHangStacks(session);
                    if (check != null) {
                        check.Fail($"タイムアウト: {message}");
                        foreach (var line in stackLines) check.Info(line);
                        try {
                            report.WriteCheck(check);
                        } catch (InvalidOperationException ex) {
                            // UI スレッドが同時に記録を足していた場合(列挙中の変更).見出しだけは残す
                            report.WriteLine($"[FAIL] {id}: タイムアウト(途中の記録を書き出せませんでした: {ex.Message})");
                            report.WriteLine($"  FAIL: {message}");
                        }
                    } else {
                        report.WriteLine($"[FAIL] watchdog: {message}");
                        foreach (var line in stackLines) report.WriteLine($"  - {line}");
                    }
                    // ミニダンプ(自プロセス)を作業フォルダに書く.光源のポートを閉じる前に書く(止まった状態をなるべく崩さないため)
                    WriteHangDump(session);
                    EmergencyCleanup(session);
                    report.WriteLine($"RESULT: FAIL (timeout{(id != null ? $": {id}" : "")})");
                    // タイムアウト時は作業フォルダ(ダンプ・撮影データ)を残す(Environment.Exit で CleanupWorkDir に届かないため,ここで残したことを出す)
                    if (HwTestJudge.ShouldKeepWorkDir(session.Options.Keep, HwTestExitCodes.Timeout) && Directory.Exists(session.WorkDir)) {
                        report.WriteLine($"work dir kept: {session.WorkDir}");
                    }
                    Environment.Exit(HwTestExitCodes.Timeout);
                }
            }) {
                IsBackground = true,
                Name = "HwTest.Watchdog",
            };
            watchdog.Start();
        }

        /// <summary>
        /// ウォッチドッグ・未処理例外からの緊急の後始末(別スレッドから呼ばれる).光源のポートを閉じる(消灯はしない.上限 5 秒).
        /// カメラ(露光・ゲインの復元・切断)は UI スレッドが SDK 呼び出しで止まっている可能性があるため触らない
        /// (露光・ゲインはカメラ側にだけ残り,次の接続でデバイス状態ファイル(IScam)・config.xml(Lucam)の値に戻る).
        /// </summary>
        private static void EmergencyCleanup(Session session) {
            var form = session.Form;
            var report = session.Report;
            if (form == null) return;
            var t = new Thread(() => {
                try {
                    form.HwTestEmergencyCloseLight();
                } catch (Exception ex) {
                    report.WriteLine($"  WARN: 光源のポートを緊急に閉じられませんでした: {SelfTestReport.DescribeException(ex)}");
                }
            }) { IsBackground = true, Name = "HwTest.EmergencyLight" };
            t.Start();
            if (t.Join(TimeSpan.FromSeconds(5))) {
                report.WriteLine("  - 緊急の後始末: 光源のポートを閉じました(消灯はしていません.カメラの露光・ゲインの復元と切断は行っていません)");
            } else {
                report.WriteLine("  WARN: 緊急の後始末: 光源のポートのクローズが 5 秒以内に終わりません(ポートが開いたままの可能性)");
            }
            if (session.RestorePending) {
                report.WriteLine($"  WARN: 露光・ゲインを元に戻せていません(元の値: 露光 {session.OriginalExposure:G6},ゲイン {session.OriginalGain:G6}.次の接続で設定ファイルの値に戻ります)");
            }
        }

        /// <summary>1 項目を実行し,想定外の例外は FAIL として記録する.実行中はウォッチドッグが項目の上限を見る</summary>
        private static SelfTestCheck RunCheck(Session session, string id, string title, TimeSpan limit, Action<SelfTestCheck> body) {
            var check = new SelfTestCheck(id, title);
            var form = session.Form;
            var errorsBefore = form?.HwTestErrors.Count ?? 0;
            lock (session.StateLock) {
                session.CurrentCheck = check;
                session.CurrentStep = "開始";
                session.CurrentLimit = limit;
                session.ItemWatch.Restart();
            }
            try {
                body(check);
            } catch (Exception ex) {
                check.FailException("想定外の例外", ex);
            }
            if (form != null) {
                var frameError = form.HwTestFrameError;
                if (frameError != null) check.Fail($"フレームの記録中の例外(ShowImage): {frameError}");
                var errors = form.HwTestErrors;
                for (var i = errorsBefore; i < errors.Count; i++) {
                    check.Fail($"エラー通知(ShowError)が発生: {errors[i]}");
                }
            }
            long elapsed;
            lock (session.StateLock) {
                elapsed = session.ItemWatch.ElapsedMilliseconds;
                session.CurrentCheck = null;
                session.CurrentStep = null;
                session.CurrentLimit = null;
            }
            check.Info($"所要時間 {elapsed} ms");
            session.Report.WriteCheck(check);
            return check;
        }

        /// <summary>実行中のステップを記録する(ハング時に結果ファイルへ出す)</summary>
        private static void SetStep(Session session, string step) {
            lock (session.StateLock) session.CurrentStep = step;
            Debug.WriteLine($"[HWTEST] {step}");
        }

        /// <summary>前提が満たされず実行しない項目を FAIL として記録する</summary>
        private static SelfTestCheck SkipCheck(Session session, string id, string reason) {
            var check = new SelfTestCheck(id, "(実行しません)");
            check.Fail(reason);
            session.Report.WriteCheck(check);
            return check;
        }

        // ================================================================================
        // 実行の流れ
        // ================================================================================

        private static int RunChecks(Session session) {
            var report = session.Report;
            var checks = new List<SelfTestCheck>();

            // WinForms の UI 例外をダイアログ表示ではなく例外として呼び出し元(DoEvents)へ伝播させる
            Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException);
            // デバイス状態ファイル({DeviceName}.xml)は相対パスで読まれるため,通常起動と同じく実行ファイルのフォルダを基準にする
            Environment.CurrentDirectory = session.BaseDir;

            var snapshot = FileSnapshot.Take(session.BaseDir);
            PrepareConfig(session);
            report.WriteLine($"  - 保存先を一時フォルダに差し替え: {session.SaveRoot}(config.xml の {session.UserSaveFolder} には書き込まない)");
            var userSaveBefore = SaveFolderSnapshot.Take(session.UserSaveFolder);

            session.Form = new Form1(Form1.HwTestMarker.Instance);
            session.Form.Show();   // Form1_Load(実機テストのモードでは自動接続しない)が走る
            SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(300));

            var connect = RunCheck(session, "connect", $"指定した機種({session.Options.Camera})に接続でき,機種名・シリアル番号が取れる",
                HwTestThresholds.ConnectTimeout, c => CheckConnect(session, c));
            checks.Add(connect);

            var ids = HwTestJudge.CheckIds(session.Options.WithChart);
            if (connect.Status == SelfTestStatus.Pass && session.Options.WithChart) {
                checks.Add(RunCheck(session, "calibrate", "実物の舌診チャートで自動校正が完了し,校正後のフレームを変換した色票が参照値に近い",
                    HwTestThresholds.CalibrateTimeout, c => CheckCalibrate(session, c)));
                checks.Add(RunCheck(session, "shot", "単発・連続撮影で撮影画像・sRGB 変換画像・Info.csv が保存され,撮影時刻の間隔が設定どおり",
                    HwTestThresholds.ShotTimeout, c => CheckShot(session, c)));
                checks.Add(RunCheck(session, "close-saving", "保存中に閉じようとすると取り消され,保存の完了後は閉じられる",
                    HwTestThresholds.CloseSavingTimeout, c => CheckCloseSaving(session, c)));
                checks.Add(RunCheck(session, "close-calibrating", "再接続して校正中に閉じる処理を呼んでもすぐ戻る",
                    HwTestThresholds.CloseCalibratingTimeout, c => CheckCloseCalibrating(session, c)));
            } else if (connect.Status == SelfTestStatus.Pass) {
                checks.Add(RunCheck(session, "preview", "プレビューが流れる(フレームレートの下限)",
                    HwTestThresholds.PreviewTimeout, c => CheckPreview(session, c)));
                checks.Add(RunCheck(session, "exposure-gain", "露光・ゲインの上げ下げがフレームの平均輝度に期待どおりの向きで効く",
                    HwTestThresholds.ExposureGainTimeout, c => CheckExposureGain(session, c)));
                checks.Add(RunCheck(session, "light", "光源の点灯・消灯が平均輝度に効き,ポートを閉じた後に開き直せる",
                    HwTestThresholds.LightTimeout, c => CheckLight(session, c)));
                checks.Add(RunCheck(session, "close-preview", "プレビュー中に閉じる処理(Form1_FormClosing)を呼んでもすぐ戻る",
                    HwTestThresholds.ClosePreviewTimeout, c => CheckClosePreview(session, c)));
                checks.Add(RunCheck(session, "reconnect", "切断した後に再接続でき,フレームが届く",
                    HwTestThresholds.ReconnectTimeout, c => CheckReconnect(session, c)));
            } else {
                var reason = session.CameraNotFound
                    ? $"指定した機種({session.Options.Camera})が見つからないため実行しません"
                    : "前提の connect が失敗したため実行しません";
                foreach (var id in ids.Skip(1).Take(ids.Length - 2)) checks.Add(SkipCheck(session, id, reason));
            }

            checks.Add(RunCheck(session, "cleanup", "後始末(露光・ゲインを戻す → カメラ切断 → 光源のポートを閉じる)と,設定・デバイス状態ファイル・本番の保存先を書き換えていない",
                HwTestThresholds.CleanupTimeout, c => CheckCleanup(session, c, snapshot, userSaveBefore)));

            var failed = checks.Where(x => x.Status == SelfTestStatus.Fail).ToList();
            var exitCode = HwTestJudge.DecideExitCode(session.CameraNotFound, failed.Count);
            report.WriteLine();
            if (session.CameraNotFound) {
                report.WriteLine($"RESULT: FAIL (camera not found: {session.Options.Camera})");
            } else if (failed.Count == 0) {
                report.WriteLine($"RESULT: PASS ({checks.Count}/{checks.Count})");
            } else {
                report.WriteLine($"RESULT: FAIL ({failed.Count}/{checks.Count} failed: {string.Join(", ", failed.Select(x => x.Id))})");
            }
            return exitCode;
        }

        /// <summary>
        /// 実機テスト用の一時設定を作り,Config に読み込む(セルフテストと共通の SelfTestConfig を使う).
        /// 同梱 config.xml を複製し,保存先を作業フォルダに,参照 CSV を実行ファイル基準の絶対パスに,効果音を存在しないパスに差し替える.
        /// </summary>
        /// <exception cref="InvalidOperationException">一時設定を準備・読み込みできない場合(実機テスト自体の異常)</exception>
        private static void PrepareConfig(Session session) {
            var prepared = SelfTestConfig.Prepare(Path.Combine(session.BaseDir, "config.xml"), session.BaseDir, session.WorkDir,
                fileName: ConfigFileName);
            if (!Config.IsLoaded()) throw new InvalidOperationException($"一時設定ファイルを読み込めません: {prepared.ConfigPath}");
            session.SaveRoot = prepared.SaveRoot;
            session.UserSaveFolder = prepared.UserSaveFolder;
        }

        // ================================================================================
        // 平均輝度の測定
        // ================================================================================

        /// <summary>設定を変えた後,フレームに反映されるまで待つ(メッセージを処理しながら)</summary>
        private static void Settle(Session session, string what) {
            SetStep(session, $"{what}: 反映待ち");
            SelfTestPump.PumpFor(HwTestThresholds.SettleTime);
        }

        /// <summary>
        /// 次に届くフレームの平均輝度と飽和画素の割合を測る(HwTestThresholds.SamplesPerMeasurement 枚.平均輝度は平均,飽和画素の割合は最大で判定).
        /// 測定値は必ず結果に出す.上限内に揃わなければ FAIL を記録し,届いた分の測定値(1 枚も無ければ NotMeasured)を返す.
        /// </summary>
        private static HwTestMeasurement MeasureBrightness(Session session, SelfTestCheck c, string label) {
            var form = session.Form;
            var n = HwTestThresholds.SamplesPerMeasurement;
            SetStep(session, $"{label}: 平均輝度の測定");
            form.HwTestRequestSamples(n);
            var ok = SelfTestPump.PumpUntil(() => form.HwTestSampleCount >= n, HwTestThresholds.SampleTimeout);
            var samples = form.HwTestTakeSamples();
            var m = HwTestJudge.Summarize(samples);
            if (!ok) {
                c.Fail($"{label}: {HwTestThresholds.SampleTimeout.TotalSeconds:0} 秒以内に {n} フレーム届きません({samples.Count} フレーム.平均輝度 {m.Mean:0.0})");
                return m;
            }
            var saturated = HwTestJudge.IsSaturated(m.SaturatedFraction, HwTestThresholds.MaxSaturatedFraction);
            c.Info($"{label}: 平均輝度 {m.Mean:0.00}({samples.Count} フレーム,最小 {samples.Min(x => x.Mean):0.00} / 最大 {samples.Max(x => x.Mean):0.00}),"
                 + $"飽和画素({HwTestThresholds.SaturatedPixelLevel} 以上)の割合 最大 {m.SaturatedFraction:P2} / 平均 {m.MeanSaturatedFraction:P2}"
                 + (saturated ? $"(飽和: しきい値 {HwTestThresholds.MaxSaturatedFraction:P0} 超え)" : ""));
            return m;
        }

        // ================================================================================
        // connect
        // ================================================================================

        private static void CheckConnect(Session session, SelfTestCheck c) {
            var form = session.Form;
            var kind = session.Options.Camera;

            SetStep(session, "機種の列挙");
            if (kind == HwTestCameraKind.Lucam) {
                int count;
                try {
                    count = Form1.HwTestCountLucam();
                } catch (Exception ex) {
                    c.FailException("Lumenera カメラの列挙(LucamNumCameras)に失敗しました(SDK 未導入の可能性)", ex);
                    session.CameraNotFound = true;
                    return;
                }
                c.Info($"Lumenera カメラ: {count} 台");
                if (count < 1) {
                    c.Fail("Lumenera カメラが見つかりません(USB の接続を確認してください)");
                    session.CameraNotFound = true;
                    return;
                }
            } else {
                string[] names;
                try {
                    names = form.HwTestListIScam();
                } catch (Exception ex) {
                    c.FailException("ImagingSource カメラの列挙(ICImagingControl.Devices)に失敗しました(SDK・ドライバ未導入の可能性)", ex);
                    session.CameraNotFound = true;
                    return;
                }
                c.Info($"ImagingSource カメラ: {names.Length} 台({string.Join(", ", names)})");
                if (names.Length < 1) {
                    c.Fail("ImagingSource カメラが見つかりません(USB の接続を確認してください)");
                    session.CameraNotFound = true;
                    return;
                }
            }

            SetStep(session, "Connect");
            var sw = Stopwatch.StartNew();
            var camera = form.HwTestConnect(kind);
            var connectErrors = form.HwTestTakeConnectErrors();
            foreach (var e in connectErrors) c.Info($"接続失敗の記録: {e}");
            if (!c.Expect(camera != null, $"Connect() に失敗しました({string.Join(" / ", connectErrors)})")) return;
            session.Camera = camera;
            session.CameraConnected = true;
            c.Info($"Connect(): {sw.ElapsedMilliseconds} ms");

            var kindOk = kind == HwTestCameraKind.Lucam ? camera is Lucam : camera is IScam;
            c.Expect(kindOk, $"接続したカメラの種類が指定と異なります: {camera.GetType().Name}(指定 {kind})");
            c.Info($"機種名: {camera.DeviceName},シリアル番号: {camera.SerialNumber}");
            c.Expect(!string.IsNullOrWhiteSpace(camera.DeviceName) && camera.DeviceName != "Unknown", $"機種名が取れません: {camera.DeviceName}");
            c.Expect(!string.IsNullOrWhiteSpace(camera.SerialNumber), "シリアル番号が取れません");
            session.FirstSerial = camera.SerialNumber;

            SetStep(session, "StartSession(画面・光源・副モニタ・再描画タイマー)");
            form.HwTestStartSession();
            c.Info($"光源のポート: {(form.HwTestLightIsOpen ? "開いた(点灯)" : "開けなかった(光源が見つからない)")}");
        }

        // ================================================================================
        // preview
        // ================================================================================

        private static void CheckPreview(Session session, SelfTestCheck c) {
            var form = session.Form;

            SetStep(session, "最初のフレームの待機");
            var sw = Stopwatch.StartNew();
            var first = SelfTestPump.PumpUntil(() => form.HwTestFrameCount > 0, HwTestThresholds.FirstFrameTimeout);
            if (!c.Expect(first, $"{HwTestThresholds.FirstFrameTimeout.TotalSeconds:0} 秒以内にフレームが届きません(接続後 {form.HwTestFrameCount} フレーム)")) return;
            c.Info($"最初のフレームまで: {sw.ElapsedMilliseconds} ms(接続処理の後から)");

            SetStep(session, "慣らし");
            SelfTestPump.PumpFor(HwTestThresholds.PreviewWarmup);

            SetStep(session, "フレームレートの測定");
            var n0 = form.HwTestFrameCount;
            var measure = Stopwatch.StartNew();
            SelfTestPump.PumpFor(HwTestThresholds.PreviewMeasure);
            var frames = form.HwTestFrameCount - n0;
            var elapsed = measure.Elapsed;
            var fps = HwTestJudge.FrameRate(frames, elapsed);
            var size = form.HwTestLastFrameSize;
            c.Info($"フレームレート: {fps:0.00} fps({frames} フレーム / {elapsed.TotalSeconds:0.00} 秒),フレームの大きさ {size.Width}x{size.Height}");
            c.Expect(HwTestJudge.MeetsMinFrameRate(fps, HwTestThresholds.MinPreviewFps),
                $"フレームレートが下限(暫定 {HwTestThresholds.MinPreviewFps} fps)を下回りました: {fps:0.00} fps");

            c.Expect(form.HwTestDisplayedImage != null, "本モニタ(picDisplay)に画像が表示されていません(MainRefreshTimer_Tick の経路)",
                "本モニタ(picDisplay)に表示用 Bitmap あり(タイマー駆動の再描画)");

            MeasureBrightness(session, c, "プレビュー");
            ExpectCalibrationStopped(session, c);
        }

        /// <summary>
        /// 校正を止めているはずの状態で校正が始まって(または済んで)いないことを確かめる(止められていなければ FAIL).
        /// 校正が走ると露光・ゲイン・光源の検査の測定が乱れ,カメラ本体のホワイトバランスも校正後の値のまま残る.
        /// </summary>
        private static void ExpectCalibrationStopped(Session session, SelfTestCheck c) {
            var camera = session.Camera;
            var calibrating = camera.IsCalibrating;
            var calibrated = camera.HwTestIsCalibrated;
            if (!session.CalibrationSuppressed) {
                c.Info($"校正の状態: {(calibrating ? "校正中" : calibrated ? "校正済み" : "未校正")}(校正は止めていない)");
                return;
            }
            c.Expect(!HwTestJudge.IsCalibrationLeaked(session.CalibrationSuppressed, calibrating, calibrated),
                $"校正を止めているはずなのに校正が{(calibrating ? "始まっています" : "済んでいます")}(CameraBase.HwTestCalibrationSuppressed が効いていません.露光・ゲイン・光源の測定が乱れます)",
                "校正は止まっています(チャート不要の検査)");
        }

        // ================================================================================
        // exposure-gain
        // ================================================================================

        private static void CheckExposureGain(Session session, SelfTestCheck c) {
            var adj = session.Camera as IHwTestAdjustable;
            if (!c.Expect(adj != null, $"このカメラ({session.Camera.GetType().Name})は露光・ゲインの操作に対応していません")) return;

            SetStep(session, "露光・ゲインの読み取り");
            var exposure = adj.HwTestGetExposure();
            var gain = adj.HwTestGetGain();
            c.Info($"露光: {exposure}(単位: {adj.HwTestExposureUnit}.値の種類: {DescribeScale(adj.HwTestExposureScale)})");
            c.Info($"ゲイン: {gain}(単位: {adj.HwTestGainUnit})");
            session.RestoreTarget = adj;
            session.OriginalExposure = exposure.Value;
            session.OriginalGain = gain.Value;
            session.OriginalGainRangeWidth = gain.Max - gain.Min;
            session.RestorePending = true;

            try {
                // 基準の明るさを中間に寄せる(飽和・真っ暗のままでは上げ下げの変化を判定できないため).
                // 動かした露光は最後に RestoreSettings で元の値へ戻す.ゲインの上げ下げもこの基準の露光で行う
                var baseExposure = FindBaselineExposure(session, c, adj, exposure);

                CheckOneSetting(session, c, "露光",
                    adj.HwTestGetExposure, adj.HwTestSetExposure, baseExposure,
                    NextExposure(adj, baseExposure, -1),
                    NextExposure(adj, baseExposure, 1),
                    HwTestJudge.ExposureCriteria());

                CheckOneSetting(session, c, "ゲイン",
                    adj.HwTestGetGain, adj.HwTestSetGain, gain,
                    HwTestJudge.StepSetting(gain.Value, gain.Min, gain.Max, -HwTestThresholds.GainStepFraction),
                    HwTestJudge.StepSetting(gain.Value, gain.Min, gain.Max, HwTestThresholds.GainStepFraction),
                    HwTestJudge.GainCriteria());

                // 元の値に戻ったことを確認する(測定値の戻りは参考.設定値の読み戻しは判定)
                RestoreSettings(session, c);
                Settle(session, "元の値");
                var restored = MeasureBrightness(session, c, "元に戻した後");
                if (!double.IsNaN(session.BaselineAfterRestoreCheck) && !HwTestJudge.IsRestored(session.BaselineAfterRestoreCheck, restored.Mean, HwTestThresholds.RestoreTolerance)) {
                    c.Warn($"元に戻した後の平均輝度が最初と離れています: {restored.Mean:0.00}(最初 {session.BaselineAfterRestoreCheck:0.00},許容 ±{HwTestThresholds.RestoreTolerance})");
                }
            } finally {
                if (session.RestorePending) {
                    // 途中で失敗しても変えた値を元に戻す
                    try {
                        RestoreSettings(session, c);
                    } catch (Exception ex) {
                        c.FailException("露光・ゲインを元に戻せませんでした(後始末で再試行します)", ex);
                    }
                }
            }
        }

        private static string DescribeScale(HwTestValueScale scale) {
            switch (scale) {
                case HwTestValueScale.Log2Step: return "2^n のきりのよい段(基準探しの前に最も近い 2^n に合わせ,1 段 = 時間で 2 倍・半分.復元は元の値そのもの)";
                case HwTestValueScale.Linear: return $"線形の量(1 段 = ×{HwTestThresholds.ExposureDownFactor:G3} / ×{HwTestThresholds.ExposureUpFactor:G3})";
                default: return scale.ToString();
            }
        }

        /// <summary>露光を 1 段動かした目標値(direction: 正で上げる,負で下げる)</summary>
        private static double NextExposure(IHwTestAdjustable adj, HwTestSetting current, int direction) {
            return HwTestJudge.NextExposure(current.Value, current.Min, current.Max, adj.HwTestExposureScale, direction,
                HwTestThresholds.ExposureLogStep, HwTestThresholds.ExposureDownFactor, HwTestThresholds.ExposureUpFactor);
        }

        private static string DescribeSearchStop(HwTestBaselineSearchDecision decision) {
            switch (decision) {
                case HwTestBaselineSearchDecision.StopNotMeasured: return "平均輝度を測定できない";
                case HwTestBaselineSearchDecision.StopRangeEnd: return "露光が範囲の端で変わらない";
                case HwTestBaselineSearchDecision.StopMaxSteps: return $"{HwTestThresholds.BaselineSearchMaxSteps} 段動かしても届かない";
                case HwTestBaselineSearchDecision.StopOvershoot: return "1 段で中間の範囲(飽和しない明るさ)を飛び越えた";
                default: return decision.ToString();
            }
        }

        /// <summary>
        /// 検査の基準にする露光を探す.まず元の設定を測り(元に戻した後の比較用に session.BaselineAfterRestoreCheck へ記録する),
        /// 露光が 2^n の段(Log2Step)なら最も近い 2^n に合わせ直して測る(以降の段との比が 2 倍・半分になるように).
        /// その明るさが基準にできる明るさ(平均輝度が中間の範囲 HwTestThresholds.MidBrightnessLow〜High かつ飽和画素の割合がしきい値以下)でなければ,
        /// 明るすぎる・飽和しているなら下げる向き,暗すぎるなら上げる向きに 1 段ずつ露光を動かし,基準にできる明るさになった設定を基準にする.
        /// 見つからなければ WARN を出し,飽和していない段を優先して中間の範囲に最も近い段を基準にする.
        /// </summary>
        /// <returns>基準にする露光(カメラから読み戻した値と範囲)</returns>
        private static HwTestSetting FindBaselineExposure(Session session, SelfTestCheck c, IHwTestAdjustable adj, HwTestSetting original) {
            var low = HwTestThresholds.MidBrightnessLow;
            var high = HwTestThresholds.MidBrightnessHigh;
            var maxSat = HwTestThresholds.MaxSaturatedFraction;
            var samples = new List<HwTestBaselineSample>();

            Settle(session, "基準探し: 元の設定");
            var m0 = MeasureBrightness(session, c, $"基準探し 元の設定(露光 {original.Value:G6})");
            session.BaselineAfterRestoreCheck = m0.Mean;

            // きりのよい段に合わせる(Log2Step のみ.Linear は元の値のまま).元の値への復元は RestoreSettings で元の値そのものに戻す
            var current = original;
            var start = m0;
            var aligned = HwTestJudge.AlignExposure(original.Value, original.Min, original.Max, adj.HwTestExposureScale);
            if (HwTestJudge.IsChanged(original.Value, aligned)) {
                SetStep(session, $"基準探し: きりのよい段に合わせる({aligned:G6})");
                adj.HwTestSetExposure(aligned);
                current = adj.HwTestGetExposure();
                Settle(session, "基準探し: きりのよい段");
                start = MeasureBrightness(session, c, $"基準探し 段 0(露光 {current.Value:G6}.元の {original.Value:G6} に最も近い 2^n に合わせた)");
            } else {
                c.Info($"基準探し 段 0: 元の露光 {original.Value:G6} をそのまま使います("
                     + (adj.HwTestExposureScale == HwTestValueScale.Log2Step ? "すでに 2^n の段" : "線形の量のため段に合わせない") + ")");
            }
            samples.Add(new HwTestBaselineSample(current.Value, start.Mean, start.SaturatedFraction));

            if (HwTestJudge.IsBaselineCandidate(start.Mean, start.SaturatedFraction, low, high, maxSat)) {
                c.Info($"基準: 露光 {current.Value:G6} の平均輝度 {start.Mean:0.00}・飽和画素の割合 {start.SaturatedFraction:P2} が基準にできる明るさ"
                     + $"(中間の範囲 暫定 {low}〜{high},飽和 {maxSat:P0} 以下)のため,そのまま基準にします");
                return current;
            }
            var direction = HwTestJudge.BaselineSearchDirection(start.Mean, start.SaturatedFraction, low, high, maxSat);
            if (direction == 0) {
                c.Warn($"基準を中間に寄せられなかった(段 0 の平均輝度・飽和画素の割合を測定できない: {start.Mean:0.00} / {start.SaturatedFraction:P2})."
                     + $"露光 {current.Value:G6} を基準にして判定します");
                return current;
            }

            var decision = HwTestBaselineSearchDecision.Continue;
            for (var step = 1; decision == HwTestBaselineSearchDecision.Continue; step++) {
                var target = NextExposure(adj, current, direction);
                SetStep(session, $"基準探し 段 {step}: 露光を{(direction < 0 ? "下げる" : "上げる")}({target:G6})");
                adj.HwTestSetExposure(target);
                var read = adj.HwTestGetExposure();
                var changed = HwTestJudge.IsChanged(current.Value, read.Value);
                var m = HwTestMeasurement.NotMeasured;
                if (changed) {
                    Settle(session, $"基準探し 段 {step}");
                    m = MeasureBrightness(session, c, $"基準探し 段 {step}(露光 {read.Value:G6})");
                    samples.Add(new HwTestBaselineSample(read.Value, m.Mean, m.SaturatedFraction));
                    current = read;
                } else {
                    c.Info($"基準探し 段 {step}: 露光が変わりませんでした(目標 {target:G6},読み戻し {read.Value:G6}.範囲の端)");
                }
                decision = HwTestJudge.DecideBaselineSearch(m.Mean, m.SaturatedFraction, direction, changed, step,
                    HwTestThresholds.BaselineSearchMaxSteps, low, high, maxSat);
            }

            var trail = string.Join(",", samples.Select(x => $"{x.Setting:G6}→{x.Mean:0.00}(飽和 {x.SaturatedFraction:P2})"));
            c.Info($"基準探しの各段(露光→平均輝度): {trail}");
            if (decision == HwTestBaselineSearchDecision.Found) {
                c.Info($"基準: 露光 {current.Value:G6}(平均輝度 {samples[samples.Count - 1].Mean:0.00}.中間の範囲 暫定 {low}〜{high},飽和 {maxSat:P0} 以下)");
                return current;
            }

            var index = HwTestJudge.ChooseBaselineIndex(samples, low, high, maxSat);
            var chosen = index >= 0 ? samples[index].Setting : current.Value;
            c.Warn($"基準を中間に寄せられなかった({DescribeSearchStop(decision)}.各段の測定値: {trail}.中間の範囲 暫定 {low}〜{high},飽和 {maxSat:P0} 以下)."
                 + $"飽和していない段を優先して中間に最も近い露光 {chosen:G6} を基準にして判定します");
            if (HwTestJudge.IsChanged(current.Value, chosen)) {
                SetStep(session, $"基準探し: 基準の露光にする({chosen:G6})");
                adj.HwTestSetExposure(chosen);
                current = adj.HwTestGetExposure();
            }
            return current;
        }

        /// <summary>
        /// 1 つの設定(露光またはゲイン)を下げる・上げるの順に変え,平均輝度の変化を criteria に従って判定する.最後に元の値(original)へ戻す.
        /// </summary>
        private static void CheckOneSetting(Session session, SelfTestCheck c, string name,
                Func<HwTestSetting> get, Action<double> set, HwTestSetting original, double lowerTarget, double raiseTarget,
                HwTestDirectionCriteria criteria) {
            Settle(session, $"{name}: 基準");
            var baseline = MeasureBrightness(session, c, $"{name}: 基準({original.Value:G6})");

            SetStep(session, $"{name}: 下げる({lowerTarget:G6})");
            set(lowerTarget);
            var lowered = get().Value;
            var lowerApplied = HwTestJudge.IsChanged(original.Value, lowered);
            Settle(session, $"{name}: 下げた値");
            var lowerMean = lowerApplied ? MeasureBrightness(session, c, $"{name}: 下げた({lowered:G6})") : HwTestMeasurement.NotMeasured;

            SetStep(session, $"{name}: 上げる({raiseTarget:G6})");
            set(raiseTarget);
            var raised = get().Value;
            var raiseApplied = HwTestJudge.IsChanged(original.Value, raised);
            Settle(session, $"{name}: 上げた値");
            var raiseMean = raiseApplied ? MeasureBrightness(session, c, $"{name}: 上げた({raised:G6})") : HwTestMeasurement.NotMeasured;

            SetStep(session, $"{name}: 元に戻す({original.Value:G6})");
            set(original.Value);

            if (!lowerApplied) c.Info($"{name}: 下げる設定が反映されませんでした(目標 {lowerTarget:G6},読み戻し {lowered:G6}.範囲の端の可能性)");
            if (!raiseApplied) c.Info($"{name}: 上げる設定が反映されませんでした(目標 {raiseTarget:G6},読み戻し {raised:G6}.範囲の端の可能性)");

            var result = HwTestJudge.JudgeDirection(baseline, lowerMean, raiseMean, lowerApplied, raiseApplied, criteria);
            var rule = criteria.UseRatio
                ? $"比で判定: 下げる ≦ {criteria.LowerLimit:G3}・上げる ≧ {criteria.RaiseLimit:G3}(暫定.単純比)"
                : $"差で判定: ±{criteria.LowerLimit:G3} 以上(暫定)";
            var policy = criteria.RequireAllChecked ? "確認した向きはすべて期待どおりであること" : "一方の向きだけでも期待どおりなら合格";
            c.Expect(result.Pass,
                $"{name}: 平均輝度の変化が期待と異なります({result.Summary}.{rule},{policy})",
                $"{name}: {result.Summary}({rule},{policy})");
        }

        /// <summary>
        /// 露光・ゲインを元の値(最初に読んだ値そのもの.IScam は絶対値)に戻し,読み戻して確かめる(戻せたら RestorePending を下ろす).
        /// 露光は相対誤差(HwTestThresholds.RestoreRelativeTolerance)で比べる.ゲインは元が 0 になりうるため,
        /// 相対誤差と「範囲の幅 × HwTestThresholds.GainRestoreRangeFraction」の大きい方で比べる.
        /// </summary>
        private static void RestoreSettings(Session session, SelfTestCheck c) {
            var adj = session.RestoreTarget;
            if (adj == null || !session.RestorePending) return;
            SetStep(session, "露光・ゲインを元に戻す");
            adj.HwTestSetExposure(session.OriginalExposure);
            adj.HwTestSetGain(session.OriginalGain);
            var exposure = adj.HwTestGetExposure().Value;
            var gain = adj.HwTestGetGain().Value;
            var rel = HwTestThresholds.RestoreRelativeTolerance;
            var exposureOk = HwTestJudge.IsSameSetting(session.OriginalExposure, exposure, rel, 0);
            var gainOk = HwTestJudge.IsSameSetting(session.OriginalGain, gain, rel,
                session.OriginalGainRangeWidth * HwTestThresholds.GainRestoreRangeFraction);
            var ok = exposureOk && gainOk;
            c.Expect(ok,
                $"露光・ゲインが元の値に戻りません(露光 {exposure:G6},元 {session.OriginalExposure:G6}{(exposureOk ? "" : "(不一致)")}"
                    + $"/ゲイン {gain:G6},元 {session.OriginalGain:G6}{(gainOk ? "" : "(不一致)")}.相対誤差の許容 {rel:P0})",
                $"露光・ゲインを元の値に戻しました(露光 {exposure:G6},元 {session.OriginalExposure:G6}/ゲイン {gain:G6},元 {session.OriginalGain:G6}.相対誤差の許容 {rel:P0})");
            c.Info(HwTestJudge.DescribeRestoredValue("露光", session.OriginalExposure, exposure)
                + "/" + HwTestJudge.DescribeRestoredValue("ゲイン", session.OriginalGain, gain));
            if (ok) session.RestorePending = false;
        }

        // ================================================================================
        // light
        // ================================================================================

        private static void CheckLight(Session session, SelfTestCheck c) {
            var form = session.Form;
            if (!c.Expect(form.HwTestLightIsOpen,
                    $"光源のポートが開いていません(光源が接続されていない,または config.xml の LightSource/Device「{Config.GetString("LightSource/Device")}」に合うポートが見つからない)")) return;
            ExpectCalibrationStopped(session, c);
            c.Expect(form.HwTestLightOn, "検査の開始時に光源が点灯状態になっていません");

            Settle(session, "点灯");
            var on1 = MeasureBrightness(session, c, "点灯").Mean;

            SetStep(session, "照明ボタン(消灯)");
            form.HwTestClickLightButton();
            c.Expect(!form.HwTestLightOn && form.HwTestLightButtonText == "照明 ON",
                $"照明ボタンで消灯状態になりません(状態 {(form.HwTestLightOn ? "点灯" : "消灯")},表示「{form.HwTestLightButtonText}」)");
            Settle(session, "消灯");
            var off = MeasureBrightness(session, c, "消灯").Mean;

            SetStep(session, "照明ボタン(点灯)");
            form.HwTestClickLightButton();
            c.Expect(form.HwTestLightOn && form.HwTestLightButtonText == "照明 OFF",
                $"照明ボタンで点灯状態に戻りません(状態 {(form.HwTestLightOn ? "点灯" : "消灯")},表示「{form.HwTestLightButtonText}」)");
            Settle(session, "再点灯");
            var on2 = MeasureBrightness(session, c, "再点灯").Mean;

            c.Expect(HwTestJudge.JudgeLight(on1, off, HwTestThresholds.MinLightDelta),
                $"点灯と消灯の平均輝度の差が下限(暫定 {HwTestThresholds.MinLightDelta})未満です: {on1 - off:0.00}(点灯 {on1:0.00},消灯 {off:0.00})",
                $"点灯と消灯の差: {on1 - off:0.00}");
            c.Expect(HwTestJudge.JudgeLight(on2, off, HwTestThresholds.MinLightDelta),
                $"再点灯と消灯の平均輝度の差が下限(暫定 {HwTestThresholds.MinLightDelta})未満です: {on2 - off:0.00}(再点灯 {on2:0.00},消灯 {off:0.00})",
                $"再点灯と消灯の差: {on2 - off:0.00}");

            SetStep(session, "光源のポートを閉じる(LightSource.Close)");
            form.HwTestCloseLight();
            c.Expect(!form.HwTestLightIsOpen, "LightSource.Close の後もポートが開いています", "ポートを閉じました(消灯はしない)");

            SetStep(session, "光源のポートを開き直す(LightSource.Open)");
            var reopened = form.HwTestReopenLight();
            if (!c.Expect(reopened && form.HwTestLightIsOpen, "閉じた後にポートを開き直せません(LightSource.Open が false)", "ポートを開き直して点灯しました")) return;
            Settle(session, "開き直して点灯");
            var on3 = MeasureBrightness(session, c, "開き直して点灯").Mean;
            c.Expect(HwTestJudge.JudgeLight(on3, off, HwTestThresholds.MinLightDelta),
                $"開き直した後の点灯が平均輝度に効いていません: 差 {on3 - off:0.00}(点灯 {on3:0.00},消灯 {off:0.00})",
                $"開き直した後の点灯と消灯の差: {on3 - off:0.00}");
        }

        // ================================================================================
        // close-preview
        // ================================================================================

        private static void CheckClosePreview(Session session, SelfTestCheck c) {
            var form = session.Form;
            if (!c.Expect(session.CameraConnected, "カメラが接続されていません")) return;

            SetStep(session, "閉じる前のフレームの確認");
            var before = form.HwTestFrameCount;
            SelfTestPump.PumpUntil(() => form.HwTestFrameCount > before, TimeSpan.FromSeconds(3));
            c.Expect(form.HwTestFrameCount > before, "閉じる前にプレビューが流れていません(プレビュー中に閉じる検査になりません)");

            SetStep(session, "閉じる処理(Form1_FormClosing → ShutdownOnClose)");
            var sw = Stopwatch.StartNew();
            bool closed = false;
            Exception error = null;
            try {
                closed = form.HwTestRequestClose();
            } catch (Exception ex) {
                error = ex;
            }
            var elapsed = sw.Elapsed;
            var steps = form.HwTestCloseSteps;
            // 後始末がカメラの切断まで進んだかで,以降の切断の要否を決める
            if (steps.Contains("camera")) session.CameraConnected = false;

            c.Info($"閉じる処理の所要時間: {elapsed.TotalMilliseconds:0} ms,通過した段階: {string.Join(" → ", steps)}");
            if (error != null) c.FailException("閉じる処理で例外", error);
            c.Expect(closed, "閉じる処理が取り消されました(保存中と判定された)");
            c.Expect(HwTestJudge.WithinLimit(elapsed, HwTestThresholds.CloseLimit),
                $"閉じる処理が上限(暫定 {HwTestThresholds.CloseLimit.TotalMilliseconds:0} ms)を超えました: {elapsed.TotalMilliseconds:0} ms");
            c.Expect(steps.SequenceEqual(HwTestJudge.CloseSequence),
                $"後始末の順序が不正です: {string.Join(" → ", steps)}(期待 closing → timer → camera → light)");
            c.Expect(form.HwTestIsClosing, "閉じ始めのフラグが立っていません");
            c.Expect(!form.HwTestRefreshTimerRunning, "本モニタの再描画タイマーが止まっていません");
            c.Expect(!form.HwTestLightIsOpen, "閉じた後も光源のポートが開いています");

            SetStep(session, "閉じた後にフレームが止まったかの確認");
            var afterStart = form.HwTestFrameCount;
            SelfTestPump.PumpFor(HwTestThresholds.AfterCloseObserve);
            var afterFrames = form.HwTestFrameCount - afterStart;
            c.Expect(afterFrames <= HwTestThresholds.MaxFramesAfterClose,
                $"閉じた後もフレームが届いています: {HwTestThresholds.AfterCloseObserve.TotalSeconds:0} 秒で {afterFrames} フレーム(上限 暫定 {HwTestThresholds.MaxFramesAfterClose})",
                $"閉じた後 {HwTestThresholds.AfterCloseObserve.TotalSeconds:0} 秒間に届いたフレーム: {afterFrames}");
        }

        // ================================================================================
        // reconnect
        // ================================================================================

        private static void CheckReconnect(Session session, SelfTestCheck c) {
            var form = session.Form;
            if (session.CameraConnected) {
                SetStep(session, "切断(Disconnect)");
                var sw0 = Stopwatch.StartNew();
                form.HwTestDisconnectCamera();
                session.CameraConnected = false;
                c.Info($"切断: {sw0.ElapsedMilliseconds} ms");
            } else {
                c.Info("閉じる処理で切断済みのカメラに再接続します");
            }

            SetStep(session, "Connect(2 回目)");
            var f0 = form.HwTestFrameCount;
            var sw = Stopwatch.StartNew();
            var camera = form.HwTestConnect(session.Options.Camera);
            var connectErrors = form.HwTestTakeConnectErrors();
            if (!c.Expect(camera != null, $"2 回目の Connect() に失敗しました({string.Join(" / ", connectErrors)})")) return;
            session.Camera = camera;
            session.CameraConnected = true;
            c.Info($"2 回目の Connect(): {sw.ElapsedMilliseconds} ms(機種名 {camera.DeviceName},シリアル番号 {camera.SerialNumber})");
            c.Expect(camera.SerialNumber == session.FirstSerial,
                $"再接続したカメラのシリアル番号が 1 回目と異なります: {camera.SerialNumber}(1 回目 {session.FirstSerial})");
            if (session.RestorePending) {
                // 接続時に露光・ゲインは設定ファイル(Lucam: config.xml,IScam: デバイス状態ファイル)の値に設定し直される
                // (1 回目の接続時と同じ値).切断済みの前のインスタンスに対して戻す必要はない
                c.Info("再接続で露光・ゲインが設定ファイルの値に戻ったため,検査で変えた値の復元は不要になりました");
                session.RestorePending = false;
            }

            SetStep(session, "再接続後のフレームの待機");
            var arrived = SelfTestPump.PumpUntil(() => form.HwTestFrameCount - f0 >= HwTestThresholds.ReconnectMinFrames,
                HwTestThresholds.ReconnectFrameTimeout);
            var frames = form.HwTestFrameCount - f0;
            c.Expect(arrived,
                $"再接続後 {HwTestThresholds.ReconnectFrameTimeout.TotalSeconds:0} 秒以内に {HwTestThresholds.ReconnectMinFrames} フレーム届きません({frames} フレーム)",
                $"再接続後 {sw.ElapsedMilliseconds} ms で {frames} フレーム到着");
        }

        // ================================================================================
        // cleanup
        // ================================================================================

        /// <summary>
        /// 後始末.順序は「変えた露光・ゲインを元に戻す → カメラ切断 → 光源のポートを閉じる」.
        /// 光源は消灯しない(アプリの終了時と同じく点いたまま残る.上流 v1.7 と同じ).
        /// 各段階は前の段階が失敗しても実行する.最後に設定・デバイス状態ファイル・本番の保存先を書き換えていないことを確かめる.
        /// </summary>
        private static void CheckCleanup(Session session, SelfTestCheck c, FileSnapshot snapshot, SaveFolderSnapshot userSaveBefore) {
            RunCleanup(session, c);

            SetStep(session, "設定・デバイス状態ファイルの確認");
            foreach (var change in snapshot.Compare(FileSnapshot.Take(session.BaseDir))) {
                c.Fail($"実行ファイルのフォルダの設定ファイルが変わりました(書き込んではならない): {change}");
            }
            c.Info($"設定・デバイス状態ファイル {snapshot.Count} 件({snapshot.Names}): 変更なし(変更があれば上に FAIL)");

            var userSaveAfter = SaveFolderSnapshot.Take(session.UserSaveFolder);
            c.Expect(userSaveBefore.SameAs(userSaveAfter),
                $"本番の保存先({session.UserSaveFolder})の今日のフォルダが変わりました: {userSaveBefore} → {userSaveAfter}",
                $"本番の保存先({session.UserSaveFolder}): 変更なし({userSaveAfter})");
        }

        private static void RunCleanup(Session session, SelfTestCheck c) {
            if (session.CleanupDone) return;
            session.CleanupDone = true;
            var form = session.Form;

            if (session.RestorePending && !session.CameraConnected) {
                c.Warn($"カメラが切断済みのため露光・ゲインを元に戻せません(元の値: 露光 {session.OriginalExposure:G6},ゲイン {session.OriginalGain:G6}.次の接続で設定ファイルの値に戻ります)");
            } else if (session.RestorePending) {
                try {
                    RestoreSettings(session, c);
                } catch (Exception ex) {
                    c.FailException("露光・ゲインを元に戻せませんでした", ex);
                }
            } else {
                c.Info("露光・ゲイン: 変更なし(または検査中に元に戻し済み)");
            }
            if (session.Options.WithChart) {
                c.Info("ホワイトバランス: 校正で変わったまま戻していません(カメラ側の値だけが変わり,次の接続でデバイス状態ファイルの値に戻ります.デバイス状態ファイルには書き込みません)");
            }

            if (session.CameraConnected) {
                SetStep(session, "カメラ切断");
                try {
                    form.HwTestDisconnectCamera();
                    session.CameraConnected = false;
                    c.Info("カメラを切断しました");
                } catch (Exception ex) {
                    c.FailException("カメラの切断に失敗しました", ex);
                }
            } else {
                c.Info("カメラ: 切断済み");
            }

            if (form != null) {
                SetStep(session, "光源のポートを閉じる");
                try {
                    form.HwTestCloseLight();
                    c.Expect(!form.HwTestLightIsOpen, "光源のポートが閉じていません", "光源: ポートを閉じました(消灯はしない.開いていなければ何もしない)");
                } catch (Exception ex) {
                    c.FailException("光源を閉じられませんでした", ex);
                }
            }
        }

        /// <summary>項目の外で例外になったときの後始末(記録は結果ファイルに直接書く)</summary>
        private static void RunCleanupSafely(Session session) {
            var check = new SelfTestCheck("cleanup", "後始末(異常終了時)");
            try {
                RunCleanup(session, check);
            } catch (Exception ex) {
                check.FailException("後始末の失敗", ex);
            }
            session.Report.WriteCheck(check);
        }

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

        // ================================================================================
        // 書き込んではならないファイルの確認
        // ================================================================================

        /// <summary>実行ファイルのフォルダの *.xml(config.xml・デバイス状態ファイル等)の内容の記録</summary>
        private sealed class FileSnapshot {
            private readonly Dictionary<string, string> _hashes;

            private FileSnapshot(Dictionary<string, string> hashes) {
                _hashes = hashes;
            }

            public int Count => _hashes.Count;

            public string Names => string.Join(", ", _hashes.Keys.OrderBy(x => x));

            public static FileSnapshot Take(string dir) {
                var hashes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
                foreach (var path in Directory.GetFiles(dir, "*.xml")) {
                    hashes[Path.GetFileName(path)] = Hash(path);
                }
                return new FileSnapshot(hashes);
            }

            public IEnumerable<string> Compare(FileSnapshot after) {
                foreach (var kv in _hashes) {
                    if (!after._hashes.TryGetValue(kv.Key, out var h)) {
                        yield return $"{kv.Key} が無くなりました";
                    } else if (h != kv.Value) {
                        yield return $"{kv.Key} の内容が変わりました";
                    }
                }
                foreach (var key in after._hashes.Keys) {
                    if (!_hashes.ContainsKey(key)) yield return $"{key} が作られました";
                }
            }

            private static string Hash(string path) {
                using (var sha = SHA256.Create())
                using (var stream = File.OpenRead(path)) {
                    return BitConverter.ToString(sha.ComputeHash(stream));
                }
            }
        }

        /// <summary>本番の保存先の今日の日付フォルダ(CreateSaveFolder が作る場所)の有無と中身の数の記録</summary>
        private sealed class SaveFolderSnapshot {
            private bool _exists;
            private int _entries;
            private string _path;

            public static SaveFolderSnapshot Take(string saveFolder) {
                var s = new SaveFolderSnapshot();
                if (string.IsNullOrEmpty(saveFolder)) return s;
                s._path = Path.Combine(saveFolder, DateTime.Now.ToString("yyyy-MM-dd"));
                try {
                    s._exists = Directory.Exists(s._path);
                    s._entries = s._exists ? Directory.GetFileSystemEntries(s._path).Length : 0;
                } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) {
                    Debug.WriteLine($"[HWTEST] 保存先を確認できません: {s._path}: {ex.Message}");
                    s._entries = -1;
                }
                return s;
            }

            public bool SameAs(SaveFolderSnapshot other) {
                return _exists == other._exists && _entries == other._entries;
            }

            public override string ToString() {
                return _exists ? $"{_path} に {_entries} 件" : $"{_path ?? "(保存先なし)"} なし";
            }
        }
    }
}
#endif