#if SELFTEST
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Windows.Forms;
using NUnit.Framework;
using OpenCvSharp;
namespace TIASshot.Tests {
/// <summary>
/// 層②(結合ロジック)テストの実行条件.<see cref="IntegrationHarness.Run(Action{IntegrationHarness}, IntegrationOptions)"/> に渡す.
/// 既定では校正を短縮する設定(<see cref="IntegrationHarness.FastConfig"/>)が入っている.
/// </summary>
internal sealed class IntegrationOptions {
/// <summary>テスト本体(後始末を除く)の上限.既定 15 秒(testing.md「結合ロジック層のテスト」)</summary>
public TimeSpan Timeout { get; set; } = IntegrationHarness.DefaultTimeout;
/// <summary>
/// 一時 config.xml に上書きする設定(キーは "Calib/Frames" のような Config のパス).
/// 値が null の項目は要素ごと削除する(設定欠落の注入).存在しないキーに値を設定すると準備時に例外になる.
/// </summary>
public Dictionary<string, string> ConfigOverrides { get; } = CopyFastConfig();
private static Dictionary<string, string> CopyFastConfig() {
var copy = new Dictionary<string, string>();
foreach (var kv in IntegrationHarness.FastConfig) copy[kv.Key] = kv.Value;
return copy;
}
/// <summary>設定を 1 件上書きする(null で要素を削除).メソッドチェーン用に自身を返す</summary>
public IntegrationOptions WithConfig(string key, string value) {
ConfigOverrides[key] = value;
return this;
}
/// <summary>テスト本体の上限を変える.メソッドチェーン用に自身を返す</summary>
public IntegrationOptions WithTimeout(TimeSpan timeout) {
Timeout = timeout;
return this;
}
}
/// <summary>
/// 層②(結合ロジック)テストの共通ハーネス(SELFTEST 限定).testing.md「結合ロジック層のテスト」・PLAN_05 フェーズ 1.
///
/// <para>
/// <see cref="Run(Action{IntegrationHarness}, IntegrationOptions)"/> は次を行い,本体(body)を専用の STA スレッド上で実行する.
/// 本体で起きた例外(NUnit のアサーション失敗を含む)はテストスレッドへ再送出する.
/// </para>
/// <list type="number">
/// <item>一時フォルダを作り,同梱 config.xml を複製した一時設定(保存先も一時フォルダ)を Config.Load で読み込む
/// (毎回読み直すため,前のテストの設定は残らない)</item>
/// <item>ヘッドレス Form1 を生成し SelfTestStart(null) でウィンドウハンドルを作る(ワーカースレッドからの Invoke を実運用と同じ経路にする)</item>
/// <item>本体を実行する</item>
/// <item>後始末: 登録したカメラを停止・切断(プレビュー停止 → 保存スレッド終了 → Disconnect),
/// SelfTestShutdown → Dispose,登録した IDisposable の解放,一時フォルダの削除</item>
/// </list>
///
/// <para>
/// 待機は <see cref="PumpUntil"/>(メッセージを処理しながら条件成立を待つ.必ずタイムアウト付き)で行う.
/// STA スレッドを Join・Sleep で止めると,SaveThread 等からの Control.Invoke が処理されず相互待機でハングする.
/// 観測は Form1.SelfTest.cs のアクセサ(SelfTestMessage・SelfTestMessages・SelfTestLastError・SelfTestErrors・
/// SelfTestShotsEnabled 等)と保存されたファイルで行う.
/// </para>
///
/// <para>
/// 同梱の config.xml・本番の保存先(C:\TIAS_Data 等)には書き込まない(保存先は <see cref="SaveRoot"/> 配下).
/// </para>
///
/// 使用例:
/// <code>
/// [Test, Category("Integration")]
/// public void ShotOne_保存成功_撮影ボタンが再有効化される() {
/// IntegrationHarness.Run(h => {
/// var camera = h.CreateChartCamera();
/// h.Calibrate(camera);
/// camera.ShotOne();
/// h.WaitSaveFinished(camera);
/// Assert.That(h.Form.SelfTestShotsEnabled, Is.True);
/// Assert.That(h.Form.SelfTestErrors, Is.Empty);
/// });
/// }
/// </code>
/// </summary>
internal sealed class IntegrationHarness {
/// <summary>テスト本体の既定の上限(1 件あたり 15 秒.testing.md)</summary>
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);
/// <summary>後始末(カメラ停止・フォーム破棄・一時フォルダ削除)に許す時間(本体の上限とは別枠)</summary>
public static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// 層②の既定の設定上書き(1 件 1 秒前後で校正まで終わるよう短縮する).
/// 同梱値: ChartSetCount=30・Frames=100・UpdateInterval=5.
/// 合成チャートは静止しているため固定判定はすぐ成立し,ゲインも少ない更新で収束する.
/// 収束の精度を検証するテストでは <see cref="IntegrationOptions.WithConfig"/> で戻すこと.
/// </summary>
public static readonly IReadOnlyDictionary<string, string> FastConfig = new Dictionary<string, string> {
{ "Calib/ChartSetCount", "3" },
{ "Calib/Frames", "10" },
{ "Calib/UpdateInterval", "2" },
};
// 校正のプレビューで流すフレーム数の余裕(固定判定・校正フレームに加えて,検出が遅れた場合の分)
private const int CalibrationExtraFrames = 60;
// Config(静的状態)と一時フォルダを複数のテストで同時に使わないための直列化
private static readonly object RunLock = new object();
private readonly Stopwatch _elapsed = Stopwatch.StartNew();
private readonly TimeSpan _timeout;
private readonly List<CameraBase> _cameras = new List<CameraBase>();
private readonly List<IDisposable> _disposables = new List<IDisposable>();
private Exception _uiException;
private Func<int, Mat> _chartFrameFactory;
// 現在待機中の内容(STA スレッドが応答しなくなった場合の失敗メッセージに使う)
private volatile string _currentWait = "開始前";
private IntegrationHarness(TimeSpan timeout) {
_timeout = timeout;
}
// ================================================================================
// 実行
// ================================================================================
/// <summary>
/// 本体を STA スレッド上で実行する(準備・後始末込み).本体の例外はテストスレッドへ再送出する.
/// </summary>
/// <param name="body">テスト本体(引数のハーネスからフォーム・待機・カメラ生成を使う)</param>
/// <param name="options">実行条件(null なら既定: 15 秒・<see cref="FastConfig"/>)</param>
public static void Run(Action<IntegrationHarness> body, IntegrationOptions options = null) {
Run<object>(h => {
body(h);
return null;
}, options);
}
/// <summary>
/// 本体を STA スレッド上で実行し,その戻り値を返す(観測値をテストスレッドで検証したい場合に使う).
/// </summary>
public static T Run<T>(Func<IntegrationHarness, T> body, IntegrationOptions options = null) {
if (body == null) throw new ArgumentNullException(nameof(body));
options = options ?? new IntegrationOptions();
// TestContext はテストスレッドで参照する(STA スレッドへ実行コンテキストが流れる前提に依存しない)
var sourceDir = FindSourceDir();
lock (RunLock) {
var harness = new IntegrationHarness(options.Timeout);
var result = default(T);
ExceptionDispatchInfo failure = null;
var thread = new Thread(() => {
try {
result = harness.Execute(body, options, sourceDir);
} catch (Exception ex) {
failure = ExceptionDispatchInfo.Capture(ex);
}
}) {
IsBackground = true, // 応答しなくなってもテストプロセスの終了を妨げない
Name = "IntegrationHarness.STA",
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
var limit = options.Timeout + CleanupTimeout;
if (!thread.Join(limit)) {
throw new TimeoutException(
$"層②テストが {limit.TotalSeconds:0} 秒(本体 {options.Timeout.TotalSeconds:0} 秒+後始末 {CleanupTimeout.TotalSeconds:0} 秒)以内に終わりませんでした." +
$"STA スレッドが応答しません(最後の待機: {harness._currentWait}.Invoke の相互待機・モーダル表示・Join による UI スレッドの停止を疑う)." +
"一時フォルダが残っている可能性があります.");
}
failure?.Throw();
return result;
}
}
private T Execute<T>(Func<IntegrationHarness, T> body, IntegrationOptions options, string sourceDir) {
// UI スレッドのメッセージ処理中の未処理例外をダイアログ表示ではなく記録にする(このスレッドのみに効く)
Application.ThreadException += (s, e) => {
Debug.WriteLine($"[IntegrationHarness] UI スレッドの未処理例外: {e.Exception}");
if (_uiException == null) _uiException = e.Exception;
};
Exception primary = null;
try {
_currentWait = "一時設定の準備";
WorkDir = Path.Combine(Path.GetTempPath(), "TIASshot-it", Guid.NewGuid().ToString("N"));
var prepared = SelfTestConfig.Prepare(Path.Combine(sourceDir, "config.xml"), sourceDir, WorkDir, options.ConfigOverrides);
if (!Config.IsLoaded()) throw new InvalidOperationException($"一時設定ファイルを読み込めません: {prepared.ConfigPath}");
ConfigPath = prepared.ConfigPath;
SaveRoot = prepared.SaveRoot;
UserSaveFolder = prepared.UserSaveFolder;
_currentWait = "ヘッドレス Form1 の生成";
Form = new Form1(Form1.SelfTestHeadless.Instance);
Form.SelfTestStart(null);
_currentWait = "テスト本体";
return body(this);
} catch (Exception ex) {
primary = ex;
throw;
} finally {
Cleanup(primary);
}
}
// ================================================================================
// 公開: 環境
// ================================================================================
/// <summary>ヘッドレス Form1(SelfTestStart 済み.ハンドル生成済み)</summary>
public Form1 Form { get; private set; }
/// <summary>このテスト専用の一時フォルダ(後始末で削除される)</summary>
public string WorkDir { get; private set; }
/// <summary>一時設定の保存先(File/SaveFolder.<see cref="WorkDir"/> 配下)</summary>
public string SaveRoot { get; private set; }
/// <summary>読み込んだ一時 config.xml のパス</summary>
public string ConfigPath { get; private set; }
/// <summary>同梱 config.xml の File/SaveFolder(書き込んでいないことの確認用)</summary>
public string UserSaveFolder { get; private set; }
/// <summary>開始からの経過時間</summary>
public TimeSpan Elapsed => _elapsed.Elapsed;
/// <summary>テスト本体の上限までの残り時間(0 未満にはならない)</summary>
public TimeSpan Remaining {
get {
var r = _timeout - _elapsed.Elapsed;
return r > TimeSpan.Zero ? r : TimeSpan.Zero;
}
}
// ================================================================================
// 公開: 待機
// ================================================================================
/// <summary>
/// 条件が成立するまでメッセージを処理して待つ.成立しなければ <see cref="TimeoutException"/> を投げる.
/// 待機中に UI スレッドで未処理例外が起きた場合はその例外(を内包した例外)を投げる.
/// </summary>
/// <param name="condition">成立を待つ条件(STA スレッドで評価する)</param>
/// <param name="what">待つ内容(失敗メッセージに使う.例: "保存スレッドの終了")</param>
/// <param name="timeout">上限(省略時はテスト本体の残り時間)</param>
public void PumpUntil(Func<bool> condition, string what, TimeSpan? timeout = null) {
var limit = timeout ?? Remaining;
var previous = _currentWait;
_currentWait = what;
try {
var ok = SelfTestPump.PumpUntil(condition, limit, ThrowIfUiException);
ThrowIfUiException();
if (!ok) {
throw new TimeoutException(
$"{what} が {limit.TotalSeconds:0.0} 秒以内に成立しませんでした(開始から {Elapsed.TotalSeconds:0.0} 秒." +
$"表示「{Form?.SelfTestMessage}」,最後のエラー通知「{Form?.SelfTestLastError ?? "なし"}」)");
}
} finally {
_currentWait = previous;
}
}
/// <summary>指定時間だけメッセージを処理する(タイマー駆動の表示更新等を反映させる).残り時間を超えては待たない</summary>
public void PumpFor(TimeSpan duration) {
var d = duration < Remaining ? duration : Remaining;
SelfTestPump.PumpFor(d, ThrowIfUiException);
ThrowIfUiException();
}
private void ThrowIfUiException() {
var ex = _uiException;
if (ex == null) return;
_uiException = null;
throw new InvalidOperationException($"UI スレッドのメッセージ処理中に未処理例外が発生しました: {ex.Message}", ex);
}
// ================================================================================
// 公開: カメラ・資源
// ================================================================================
/// <summary>
/// カメラを後始末の対象に登録する(終了時にプレビュー停止 → 保存スレッド終了待ち → Disconnect).
/// テスト内で CameraBase を派生させたカメラもこれで登録する.
/// </summary>
public T Track<T>(T camera) where T : CameraBase {
if (camera != null) _cameras.Add(camera);
return camera;
}
/// <summary>資源を後始末で解放する対象に登録する(Form1 の破棄後,登録の逆順に Dispose)</summary>
public T AddDisposable<T>(T disposable) where T : IDisposable {
if (disposable != null) _disposables.Add(disposable);
return disposable;
}
/// <summary>
/// 合成の舌診チャート(SelfTestChart.色票は参照値 tcc_srgb を基にする)のフレームを返す生成関数.
/// 呼び出しごとに新しい Mat(所有権は呼び出し側=偽カメラ)を返す.元のチャート画像はハーネスが解放する.
/// </summary>
public Func<int, Mat> ChartFrameFactory() {
if (_chartFrameFactory == null) {
var tccSrgb = LoadTccSrgb();
var chart = AddDisposable(SelfTestChart.CreateChartFrame(tccSrgb));
_chartFrameFactory = i => chart.Clone();
}
return _chartFrameFactory;
}
/// <summary>参照値 tcc_srgb.csv(24x3)を読み込む(後始末で解放される)</summary>
public Mat LoadTccSrgb() {
var path = Config.GetString("File/TccSrgbRef");
var m = IoUtil.LoadMatFromCsv(path);
if (m == null) throw new InvalidOperationException($"参照ファイルを読み込めません: {path}");
return AddDisposable(m);
}
/// <summary>合成チャートを流す偽カメラを生成・登録し,接続(BootCheck)する</summary>
public SelfTestCamera CreateChartCamera() {
return CreateCamera(ChartFrameFactory());
}
/// <summary>任意のフレームを流す偽カメラを生成・登録し,接続(BootCheck)する</summary>
/// <param name="frameFactory">フレーム番号から新しい Mat を返す関数(戻り値の所有権は偽カメラへ移る)</param>
public SelfTestCamera CreateCamera(Func<int, Mat> frameFactory) {
return Connect(Track(new SelfTestCamera(Form, frameFactory)));
}
/// <summary>カメラを接続する.失敗したら ErrorMsg を含む例外を投げる</summary>
public T Connect<T>(T camera) where T : CameraBase {
if (!camera.Connect()) throw new InvalidOperationException($"カメラの接続に失敗しました: {camera.ErrorMsg}");
return camera;
}
/// <summary>
/// 偽カメラのプレビューを流して自動校正(チャート検出 → 固定判定 → ホワイトバランス → 変換行列算出)を完了させる.
/// 完了しなければ例外を投げる.完了時点で撮影ボタンは有効化されている(CalcTcc → EnableShots).
/// </summary>
public void Calibrate(SelfTestCamera camera) {
var maxFrames = Config.GetInt("Calib/ChartSetCount") + Config.GetInt("Calib/Frames") + CalibrationExtraFrames;
camera.StartPreview(maxFrames, 0, true);
PumpUntil(() => !camera.IsPreviewRunning, "校正のプレビュー終了");
if (camera.PreviewError != null) {
throw new InvalidOperationException($"校正中のプレビューで例外が発生しました: {camera.PreviewError.Message}", camera.PreviewError);
}
if (!camera.IsCalibrated) {
throw new InvalidOperationException(
$"校正が完了しませんでした({camera.FramesDelivered} フレーム.表示「{Form.SelfTestMessage}」.チャート検出の失敗を疑う)");
}
PumpUntil(() => Form.SelfTestShotsEnabled, "校正完了後の撮影ボタン有効化");
}
/// <summary>
/// 撮影の保存スレッド(SaveThread)の終了を待つ.終了時点で SaveThread からの通知
/// (ShowMessage・ShowError・EnableShots の Invoke)は処理済みである.
/// </summary>
public void WaitSaveFinished(CameraBase camera, TimeSpan? timeout = null) {
PumpUntil(() => !camera.IsSaveThreadStarted, "保存スレッドの終了", timeout);
}
// ================================================================================
// 後始末
// ================================================================================
private void Cleanup(Exception primary) {
var deadline = Stopwatch.StartNew();
var errors = new List<string>();
Func<TimeSpan> left = () => {
var r = CleanupTimeout - deadline.Elapsed;
return r > TimeSpan.Zero ? r : TimeSpan.Zero;
};
_currentWait = "後始末";
for (var i = _cameras.Count - 1; i >= 0; i--) {
var camera = _cameras[i];
try {
var fake = camera as SelfTestCamera;
if (fake != null) {
fake.RequestStopPreview();
// プレビュースレッドは Invoke で UI スレッドを待つことがあるため,Join ではなくメッセージを回して待つ
if (!SelfTestPump.PumpUntil(() => !fake.IsPreviewRunning, left())) errors.Add("後始末: 偽カメラのプレビューが停止しません");
}
if (!SelfTestPump.PumpUntil(() => !camera.IsSaveThreadStarted, left())) errors.Add("後始末: 保存スレッドが終了しません");
camera.Disconnect();
} catch (Exception ex) {
errors.Add($"後始末: カメラの切断に失敗しました: {ex}");
}
}
if (Form != null) {
try {
Form.SelfTestShutdown();
Form.Dispose();
} catch (Exception ex) {
errors.Add($"後始末: Form1 の破棄に失敗しました: {ex}");
}
}
for (var i = _disposables.Count - 1; i >= 0; i--) {
try {
_disposables[i].Dispose();
} catch (Exception ex) {
errors.Add($"後始末: 資源の解放に失敗しました: {ex}");
}
}
if (_uiException != null) {
errors.Add($"後始末: UI スレッドの未処理例外(未検査): {_uiException}");
}
DeleteWorkDir();
if (errors.Count == 0) return;
var message = string.Join(Environment.NewLine, errors);
if (primary != null) {
// 本体の失敗を優先して報告する(後始末の失敗はログに残す)
Debug.WriteLine($"[IntegrationHarness] {message}");
TestContext.Progress.WriteLine($"[IntegrationHarness] {message}");
return;
}
throw new InvalidOperationException(message);
}
private void DeleteWorkDir() {
if (string.IsNullOrEmpty(WorkDir) || !Directory.Exists(WorkDir)) return;
try {
Directory.Delete(WorkDir, true);
} catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) {
// 削除できなくてもテストの判定には影響しない(%TEMP% 配下のため手動削除可能)
Debug.WriteLine($"[IntegrationHarness] 一時フォルダを削除できませんでした: {WorkDir}: {ex.Message}");
TestContext.Progress.WriteLine($"[IntegrationHarness] 一時フォルダを削除できませんでした: {WorkDir}: {ex.Message}");
}
}
/// <summary>同梱 config.xml・参照 CSV のあるフォルダ(リポジトリの TIASshot/.読むだけ)</summary>
private static string FindSourceDir() {
var dir = Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "TIASshot"));
if (!File.Exists(Path.Combine(dir, "config.xml"))) {
throw new FileNotFoundException($"同梱 config.xml が見つかりません(TIASshot.Tests/bin/x64/<構成>/ から見たリポジトリの TIASshot/ を想定): {dir}");
}
return dir;
}
}
}
#endif