diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md
index 3356866..7a500cf 100644
--- a/.claude/rules/testing.md
+++ b/.claude/rules/testing.md
@@ -101,7 +101,7 @@
層②のテストは,カメラの代わりに偽カメラ(`SelfTest/SelfTestCamera`.`CameraBase` の派生)を,フォームの代わりにヘッドレス `Form1`(`Form1.SelfTest.cs`)を使う.どちらも `SELFTEST` フラグ(Debug 系の構成のみ)でコンパイルされるため,層②のテストも `#if SELFTEST` で囲む.
-- **STA スレッドで動かす**: WinForms のコントロールと `Control.Invoke` は STA スレッドとメッセージ処理を必要とする.テストは共通ハーネス(PLAN_05 フェーズ 1 で作成)を通して,STA スレッド上でヘッドレス `Form1` を生成し,`SelfTestStart` でハンドルを作ってから使う.ハーネスを経由せずに個別のテストでスレッドやメッセージ処理を組まない
+- **STA スレッドで動かす**: WinForms のコントロールと `Control.Invoke` は STA スレッドとメッセージ処理を必要とする.テストは共通ハーネス(`TIASshot.Tests/IntegrationHarness.cs`.使用例は `CameraBaseSaveIntegrationTests`)を通して,STA スレッド上でヘッドレス `Form1` を生成し,`SelfTestStart` でハンドルを作ってから使う.ハーネスを経由せずに個別のテストでスレッドやメッセージ処理を組まない
- **待機には必ずタイムアウトを付ける**: 「条件が満たされるまでメッセージを処理して待つ」処理は無限待ちにしない.タイムアウトしたらテストを失敗させ,ハングで後続のテストを止めない.層②の各テストには **1 件あたり 15 秒**のタイムアウトを付ける
- **実行時間の目安**: 層②のテスト全体で **30 秒以内**を目安にする.テスト用の一時設定では校正フレーム数(`Calib/Frames`)等を小さくし,1 件あたり 1 秒前後で終わるようにする.目安を超えそうなときは,テストを減らしたり `/verify` から外したりせず,テスト用設定を見直して速くする(`/auto-audit` は修正 1 件ごとにテスト一式を何度も回すため,ここが延びると全作業が遅くなる)
- **観測はアクセサ経由で行う**: 結果は `Form1.SelfTest.cs` のアクセサ(`SelfTestMessage`・`SelfTestLastError`・`SelfTestShotsEnabled` 等)と,保存されたファイルで確かめる.`Form1` の private フィールドをリフレクションで読まない.足りないアクセサは `Form1.SelfTest.cs` に追加する
diff --git a/CLAUDE.md b/CLAUDE.md
index 7e76617..2698151 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@
## 開発進捗
-最新: /auto-audit(全観点)で見つけたバグ 7 件を再現テスト付きで修正し main に取り込んだ(要対応 26 件は報告台帳)
+最新: PLAN_05 フェーズ 1 として層②テストの共通ハーネスを作り,保存失敗が成功扱いになる不具合を赤→緑で修正した
※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる).
## 必須ルール(コード実装時)
diff --git a/TIASshot.Tests/BitmapUtilTests.cs b/TIASshot.Tests/BitmapUtilTests.cs
index 0e29218..96cb08e 100644
--- a/TIASshot.Tests/BitmapUtilTests.cs
+++ b/TIASshot.Tests/BitmapUtilTests.cs
@@ -273,6 +273,7 @@
/// プライベートメモリ増分を測り,複製先バッファ相当(約 64MB)が残っていないことを確認する.
///
[Test]
+ [Category("Memory")]
public void FastClone_入力が既にロック中でLockBitsが失敗_複製先Bitmapのネイティブメモリを残さない() {
const int size = 4096; // 32bppArgb で 4096*4096*4 = 64MB
using (var src = new Bitmap(size, size, PixelFormat.Format32bppArgb)) {
diff --git a/TIASshot.Tests/CameraBaseSaveIntegrationTests.cs b/TIASshot.Tests/CameraBaseSaveIntegrationTests.cs
new file mode 100644
index 0000000..42a1251
--- /dev/null
+++ b/TIASshot.Tests/CameraBaseSaveIntegrationTests.cs
@@ -0,0 +1,251 @@
+#if SELFTEST
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+using OpenCvSharp;
+
+namespace TIASshot.Tests {
+
+ ///
+ /// CameraBase の撮影・保存(ShotOne / ShotMulti → RunShotLoop → SaveThread → SaveImages)の結合ロジックテスト(層②).
+ ///
+ /// 仕様: SPEC_01「撮影」「撮影失敗時の挙動」「データ保存」
+ /// - 保存される画像: 元画像 `Shot{NO}.png`,sRGB 変換画像 `sRGB{CN}_{NO}.jpg`(指定した次元数ぶん),撮影情報 `Info.csv`
+ /// - 保存先: `[SaveFolder]/[日付]/[時刻]-[データ名]/`
+ /// - 画像の保存に失敗した場合は,保存処理の終了後にダイアログで通知する
+ /// - 撮影ボタンは保存が完了するまで無効のままとなり,保存処理の終了後に再び操作可能になる
+ ///
+ /// 失敗の注入: 撮影開始時(BeginShot.保存フォルダ作成後・Info.csv 作成前)に,保存先フォルダ内へ
+ /// 保存予定の画像ファイルと同名のフォルダ(例 `Shot0001.png\`)を作る.Cv2.ImWrite はファイルを開けないと
+ /// 例外を出さず false を返すため,IoUtil.SaveImage が false を返す状況になる(前提は
+ /// で確認する).
+ /// 例外ではなく false で注入するのは,戻り値の無視(保存失敗が「全ての画像保存完了」と表示される)を検出するため.
+ ///
+ /// 観測は Form1.SelfTest.cs のアクセサ(SelfTestErrors・SelfTestMessages・SelfTestShotsEnabled)と保存ファイルで行う.
+ /// 観測値は STA スレッド上で集めてテストスレッドへ返し,アサーションはテストスレッドで行う.
+ /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる.
+ ///
+ [TestFixture]
+ public class CameraBaseSaveIntegrationTests {
+
+ // SaveThread が全枚数の保存に成功したときの表示(SPEC_01: 保存成功時のみ完了と表示する)
+ private const string AllSavedMessage = "全ての画像保存完了";
+
+ // 保存失敗の通知に含まれるべき語(ダイアログ=ShowError で保存失敗と分かること)
+ private const string SaveFailedPhrase = "保存に失敗";
+
+ // ================================================================================
+ // 前提: 失敗注入の手段そのものの確認(層①)
+ // ================================================================================
+
+ ///
+ /// 保存先パスに同名のフォルダがある場合,IoUtil.SaveImage は例外を出さず false を返す.
+ /// 以降の結合テストの失敗注入(同名フォルダ)が「false 返却」の経路に乗ることの前提確認.
+ /// これが赤なら注入方法を見直すこと(例外になる場合は修正前のコードでも失敗経路に乗ってしまう).
+ ///
+ [Test]
+ public void SaveImage_保存先と同名のフォルダがある_例外を出さずfalseを返す() {
+ // Arrange
+ var dir = Path.Combine(Path.GetTempPath(), "TIASshotTests_" + Guid.NewGuid().ToString("N"));
+ var path = Path.Combine(dir, "Shot0001.png");
+ Directory.CreateDirectory(path);
+ try {
+ using (var img = new Mat(4, 4, MatType.CV_8UC3, Scalar.All(128))) {
+ var result = true;
+
+ // Act
+ Assert.DoesNotThrow((Action)(() => result = IoUtil.SaveImage(path, img)));
+
+ // Assert
+ Assert.That(result, Is.False, "同名フォルダがあり書き込めない場合は false で報告されること");
+ }
+ } finally {
+ if (Directory.Exists(dir)) Directory.Delete(dir, true);
+ }
+ }
+
+ // ================================================================================
+ // 正常系
+ // ================================================================================
+
+ ///
+ /// 単発撮影で保存に成功したとき,撮影画像・sRGB 変換画像(全次元)・Info.csv が保存先に作られ,
+ /// 「全ての画像保存完了」が表示され,エラー通知が無く,撮影ボタンが再び有効になる.
+ ///
+ [Test, Category("Integration")]
+ public void ShotOne_保存成功_全ファイルが保存され完了表示と撮影ボタン再有効化が行われる() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.CreateChartCamera();
+ h.Calibrate(camera);
+ return ShootAndObserve(h, camera, multi: false);
+ });
+
+ // Assert
+ Assert.That(obs.Channels, Is.Not.Empty, "前提: 変換次元が 1 つ以上設定されていること");
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.SaveFolderUnderSaveRoot, Is.True, $"保存先が一時保存先の配下であること: {obs.SaveFolder}");
+ Assert.That(obs.SavedFiles, Is.SupersetOf(obs.ExpectedFilesForNo1), "撮影画像・sRGB 変換画像・Info.csv が保存されること");
+ Assert.That(obs.InfoHasShotLogHeader, Is.True, "Info.csv に撮影ログのヘッダ(Shot,Time(ms),Note)があること");
+ Assert.That(obs.Messages, Has.Member(AllSavedMessage), "保存成功時は「全ての画像保存完了」が表示されること");
+ Assert.That(obs.Errors, Is.Empty, "保存成功時はエラー通知が無いこと");
+ Assert.That(obs.ShotsEnabled, Is.True, "保存完了後に撮影ボタンが再び有効になること");
+ }
+ }
+
+ // ================================================================================
+ // 失敗注入
+ // ================================================================================
+
+ ///
+ /// 単発撮影で撮影画像の保存に失敗したとき(IoUtil.SaveImage が false),
+ /// 保存失敗が通知され,「全ての画像保存完了」は表示されず,撮影ボタンは再び有効になる.
+ ///
+ [Test, Category("Integration")]
+ public void ShotOne_撮影画像の保存失敗_保存失敗を通知し完了と表示せず撮影ボタンを再有効化する() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.Connect(h.Track(new SaveBlockingCamera(h.Form, h.ChartFrameFactory(), 1)));
+ h.Calibrate(camera);
+ return ShootAndObserve(h, camera, multi: false);
+ });
+
+ // Assert
+ AssertSaveFailureReported(obs);
+ }
+
+ ///
+ /// 連続撮影(3 枚)で 2 枚目の撮影画像の保存に失敗したとき,
+ /// 保存失敗が通知され,「全ての画像保存完了」は表示されず,撮影ボタンは再び有効になる.
+ ///
+ [Test, Category("Integration")]
+ public void ShotMulti_2枚目の保存失敗_保存失敗を通知し完了と表示せず撮影ボタンを再有効化する() {
+ // Arrange / Act
+ var obs = IntegrationHarness.Run(h => {
+ var camera = h.Connect(h.Track(new SaveBlockingCamera(h.Form, h.ChartFrameFactory(), 2)));
+ h.Calibrate(camera);
+ h.Form.SelfTestSetMultiShots("3", "0");
+ return ShootAndObserve(h, camera, multi: true);
+ });
+
+ // Assert
+ AssertSaveFailureReported(obs);
+ }
+
+ // ================================================================================
+ // 共通処理
+ // ================================================================================
+
+ private static void AssertSaveFailureReported(ShotObservation obs) {
+ Assert.That(obs.InjectedPaths, Is.Not.Empty, "前提: 失敗注入(保存先と同名のフォルダ)が行われたこと");
+ using (Assert.EnterMultipleScope()) {
+ Assert.That(obs.Errors, Has.Some.Matches(e => e != null && e.Contains(SaveFailedPhrase)),
+ $"保存失敗がエラー通知(ShowError)されること.通知: [{string.Join(" / ", obs.Errors)}]");
+ Assert.That(obs.Messages, Has.No.Member(AllSavedMessage),
+ $"保存に失敗したのに「{AllSavedMessage}」と表示しないこと.表示履歴: [{string.Join(" / ", obs.Messages)}]");
+ Assert.That(obs.ShotsEnabled, Is.True, "保存失敗後も撮影ボタンが再び有効になること(UI が固着しない)");
+ }
+ }
+
+ ///
+ /// 撮影ボタン押下(Form1.ExecuteShot)と同じく撮影ボタンを無効化してから撮影し,保存スレッドの終了を待って観測値を集める.
+ /// 無効化しておかないと,再有効化されたかどうかを観測できない(校正完了で既に有効なため).
+ ///
+ private static ShotObservation ShootAndObserve(IntegrationHarness h, SelfTestCamera camera, bool multi) {
+ h.Form.SelfTestClearLog();
+ h.Form.EnableShots(false);
+ if (multi) {
+ camera.ShotMulti();
+ } else {
+ camera.ShotOne();
+ }
+ h.WaitSaveFinished(camera);
+
+ var folder = camera.CurrentSaveFolder;
+ var obs = new ShotObservation {
+ Errors = h.Form.SelfTestErrors.ToList(),
+ Messages = h.Form.SelfTestMessages.ToList(),
+ ShotsEnabled = h.Form.SelfTestShotsEnabled,
+ SaveFolder = folder,
+ Channels = GetConversionChannels(),
+ InjectedPaths = (camera as SaveBlockingCamera)?.InjectedPaths.ToList() ?? new List(),
+ };
+ // 期待ファイル名は一時設定が読み込まれている STA スレッド上で組み立てる(SPEC_01「撮影保存ファイル」)
+ obs.ExpectedFilesForNo1.Add(Config.GetString("File/Info"));
+ obs.ExpectedFilesForNo1.Add(ShotFileName("File/RgbImage", 0, 1));
+ obs.ExpectedFilesForNo1.AddRange(obs.Channels.Select(ch => ShotFileName("File/SrgbImage", ch, 1)));
+ if (!string.IsNullOrEmpty(folder) && Directory.Exists(folder)) {
+ obs.SaveFolderUnderSaveRoot = IsUnder(folder, h.SaveRoot);
+ obs.SavedFiles = Directory.GetFiles(folder).Select(Path.GetFileName).ToList();
+ var info = Path.Combine(folder, Config.GetString("File/Info"));
+ obs.InfoHasShotLogHeader = File.Exists(info) && File.ReadAllText(info).Contains("Shot,Time(ms),Note");
+ }
+ return obs;
+ }
+
+ /// SPEC_01 のファイル名規則({CN}: 変換次元 2 桁,{NO}: 連番 4 桁)で保存ファイル名を作る
+ private static string ShotFileName(string key, int channel, int no) {
+ return Config.GetString(key).Replace("{CN}", $"{channel:00}").Replace("{NO}", $"{no:0000}");
+ }
+
+ /// 設定の変換次元(Calib/ConversionChannels.SPEC_02: カンマ区切り・4〜17 の範囲のみ有効)
+ private static List GetConversionChannels() {
+ return Config.GetString("Calib/ConversionChannels")
+ .Split(',')
+ .Select(x => int.Parse(x.Trim()))
+ .Where(x => x >= 4 && x <= 17)
+ .Distinct()
+ .OrderBy(x => x)
+ .ToList();
+ }
+
+ private static bool IsUnder(string path, string root) {
+ var p = Path.GetFullPath(path).TrimEnd('\\') + "\\";
+ var r = Path.GetFullPath(root).TrimEnd('\\') + "\\";
+ return p.StartsWith(r, StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// STA スレッドで集めた観測値(テストスレッドで検証する)
+ private sealed class ShotObservation {
+ public List Errors = new List();
+ public List Messages = new List();
+ public bool ShotsEnabled;
+ public string SaveFolder;
+ public bool SaveFolderUnderSaveRoot;
+ public List SavedFiles = new List();
+ public bool InfoHasShotLogHeader;
+ public List Channels = new List();
+ public List InjectedPaths = new List();
+ public List ExpectedFilesForNo1 = new List();
+ }
+
+ ///
+ /// 指定した連番の撮影画像(Shot{NO}.png)を保存できなくする偽カメラ(失敗注入用).
+ /// 撮影開始時(保存フォルダ作成後)に,保存先フォルダへ同名のフォルダを作る.
+ /// これにより Cv2.ImWrite が false を返し,IoUtil.SaveImage が false を返す(例外は出ない).
+ ///
+ private sealed class SaveBlockingCamera : SelfTestCamera {
+
+ private readonly int[] _blockedNos;
+
+ public SaveBlockingCamera(Form1 form, Func frameFactory, params int[] blockedNos) : base(form, frameFactory) {
+ _blockedNos = blockedNos;
+ }
+
+ /// 作成した同名フォルダのパス
+ public List InjectedPaths { get; } = new List();
+
+ protected override void BeginShot() {
+ base.BeginShot();
+ foreach (var no in _blockedNos) {
+ var path = Path.Combine(CurrentSaveFolder, ShotFileName("File/RgbImage", 0, no));
+ Directory.CreateDirectory(path);
+ InjectedPaths.Add(path);
+ }
+ }
+ }
+ }
+}
+#endif
diff --git a/TIASshot.Tests/ChartDetectorTests.cs b/TIASshot.Tests/ChartDetectorTests.cs
index 9281b0f..d97a7e9 100644
--- a/TIASshot.Tests/ChartDetectorTests.cs
+++ b/TIASshot.Tests/ChartDetectorTests.cs
@@ -218,6 +218,7 @@
/// 解放されていれば増分はほぼ 0 になる.
///
[Test]
+ [Category("Memory")]
public void DetectChart_固定判定成立後の検出を繰り返す_中間Matのネイティブメモリを滞留させない() {
const int Iterations = 10;
const long LeakThresholdBytes = 20L * 1024 * 1024; // 未解放なら約 37MB 増える
diff --git a/TIASshot.Tests/ColorCorrectorTests.cs b/TIASshot.Tests/ColorCorrectorTests.cs
index c57822e..e318edc 100644
--- a/TIASshot.Tests/ColorCorrectorTests.cs
+++ b/TIASshot.Tests/ColorCorrectorTests.cs
@@ -573,6 +573,7 @@
/// 大きなバッファが GC まで解放されずピークメモリが膨らむ)
///
[Test]
+ [Category("Memory")]
public void ConvertImage_通常入力_行列積のMatExprが拡張Matのバッファ参照を残さない() {
// Arrange
using (var src = new Mat(2, 2, MatType.CV_8UC3, new Scalar(50, 100, 150)))
diff --git a/TIASshot.Tests/ContinuousShootMemoryBenchmark.cs b/TIASshot.Tests/ContinuousShootMemoryBenchmark.cs
index 27273e5..97701e4 100644
--- a/TIASshot.Tests/ContinuousShootMemoryBenchmark.cs
+++ b/TIASshot.Tests/ContinuousShootMemoryBenchmark.cs
@@ -45,6 +45,7 @@
[TestFixture]
[Explicit("メモリ計測ベンチ.連続撮影フローを合成フレームで構造再現するため明示実行のみ.")]
[Category("Benchmark")]
+ [Category("Memory")]
public class ContinuousShootMemoryBenchmark {
// ---- 環境変数ヘルパー(EnvInt / EnvDouble は MemBenchSupport を利用) ----
diff --git a/TIASshot.Tests/ConvertImageMemoryBenchmark.cs b/TIASshot.Tests/ConvertImageMemoryBenchmark.cs
index 6a87d35..31c13a7 100644
--- a/TIASshot.Tests/ConvertImageMemoryBenchmark.cs
+++ b/TIASshot.Tests/ConvertImageMemoryBenchmark.cs
@@ -29,6 +29,7 @@
[TestFixture]
[Explicit("メモリ計測ベンチマーク.フルスケールで大量のメモリを確保するため明示実行のみ.")]
[Category("Benchmark")]
+ [Category("Memory")]
public class ConvertImageMemoryBenchmark {
[Test]
diff --git a/TIASshot.Tests/IntegrationHarness.cs b/TIASshot.Tests/IntegrationHarness.cs
new file mode 100644
index 0000000..906a6ee
--- /dev/null
+++ b/TIASshot.Tests/IntegrationHarness.cs
@@ -0,0 +1,456 @@
+#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 {
+
+ ///
+ /// 層②(結合ロジック)テストの実行条件. に渡す.
+ /// 既定では校正を短縮する設定()が入っている.
+ ///
+ internal sealed class IntegrationOptions {
+
+ /// テスト本体(後始末を除く)の上限.既定 15 秒(testing.md「結合ロジック層のテスト」)
+ public TimeSpan Timeout { get; set; } = IntegrationHarness.DefaultTimeout;
+
+ ///
+ /// 一時 config.xml に上書きする設定(キーは "Calib/Frames" のような Config のパス).
+ /// 値が null の項目は要素ごと削除する(設定欠落の注入).存在しないキーに値を設定すると準備時に例外になる.
+ ///
+ public Dictionary ConfigOverrides { get; } = CopyFastConfig();
+
+ private static Dictionary CopyFastConfig() {
+ var copy = new Dictionary();
+ foreach (var kv in IntegrationHarness.FastConfig) copy[kv.Key] = kv.Value;
+ return copy;
+ }
+
+ /// 設定を 1 件上書きする(null で要素を削除).メソッドチェーン用に自身を返す
+ public IntegrationOptions WithConfig(string key, string value) {
+ ConfigOverrides[key] = value;
+ return this;
+ }
+
+ /// テスト本体の上限を変える.メソッドチェーン用に自身を返す
+ public IntegrationOptions WithTimeout(TimeSpan timeout) {
+ Timeout = timeout;
+ return this;
+ }
+ }
+
+ ///
+ /// 層②(結合ロジック)テストの共通ハーネス(SELFTEST 限定).testing.md「結合ロジック層のテスト」・PLAN_05 フェーズ 1.
+ ///
+ ///
+ /// は次を行い,本体(body)を専用の STA スレッド上で実行する.
+ /// 本体で起きた例外(NUnit のアサーション失敗を含む)はテストスレッドへ再送出する.
+ ///
+ ///
+ /// - 一時フォルダを作り,同梱 config.xml を複製した一時設定(保存先も一時フォルダ)を Config.Load で読み込む
+ /// (毎回読み直すため,前のテストの設定は残らない)
+ /// - ヘッドレス Form1 を生成し SelfTestStart(null) でウィンドウハンドルを作る(ワーカースレッドからの Invoke を実運用と同じ経路にする)
+ /// - 本体を実行する
+ /// - 後始末: 登録したカメラを停止・切断(プレビュー停止 → 保存スレッド終了 → Disconnect),
+ /// SelfTestShutdown → Dispose,登録した IDisposable の解放,一時フォルダの削除
+ ///
+ ///
+ ///
+ /// 待機は (メッセージを処理しながら条件成立を待つ.必ずタイムアウト付き)で行う.
+ /// STA スレッドを Join・Sleep で止めると,SaveThread 等からの Control.Invoke が処理されず相互待機でハングする.
+ /// 観測は Form1.SelfTest.cs のアクセサ(SelfTestMessage・SelfTestMessages・SelfTestLastError・SelfTestErrors・
+ /// SelfTestShotsEnabled 等)と保存されたファイルで行う.
+ ///
+ ///
+ ///
+ /// 同梱の config.xml・本番の保存先(C:\TIAS_Data 等)には書き込まない(保存先は 配下).
+ ///
+ ///
+ /// 使用例:
+ ///
+ /// [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);
+ /// });
+ /// }
+ ///
+ ///
+ internal sealed class IntegrationHarness {
+
+ /// テスト本体の既定の上限(1 件あたり 15 秒.testing.md)
+ public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);
+
+ /// 後始末(カメラ停止・フォーム破棄・一時フォルダ削除)に許す時間(本体の上限とは別枠)
+ public static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
+
+ ///
+ /// 層②の既定の設定上書き(1 件 1 秒前後で校正まで終わるよう短縮する).
+ /// 同梱値: ChartSetCount=30・Frames=100・UpdateInterval=5.
+ /// 合成チャートは静止しているため固定判定はすぐ成立し,ゲインも少ない更新で収束する.
+ /// 収束の精度を検証するテストでは で戻すこと.
+ ///
+ public static readonly IReadOnlyDictionary FastConfig = new Dictionary {
+ { "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 _cameras = new List();
+ private readonly List _disposables = new List();
+ private Exception _uiException;
+ private Func _chartFrameFactory;
+
+ // 現在待機中の内容(STA スレッドが応答しなくなった場合の失敗メッセージに使う)
+ private volatile string _currentWait = "開始前";
+
+ private IntegrationHarness(TimeSpan timeout) {
+ _timeout = timeout;
+ }
+
+ // ================================================================================
+ // 実行
+ // ================================================================================
+
+ ///
+ /// 本体を STA スレッド上で実行する(準備・後始末込み).本体の例外はテストスレッドへ再送出する.
+ ///
+ /// テスト本体(引数のハーネスからフォーム・待機・カメラ生成を使う)
+ /// 実行条件(null なら既定: 15 秒・)
+ public static void Run(Action body, IntegrationOptions options = null) {
+ Run