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(h => { + body(h); + return null; + }, options); + } + + /// + /// 本体を STA スレッド上で実行し,その戻り値を返す(観測値をテストスレッドで検証したい場合に使う). + /// + public static T Run(Func 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(Func 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); + } + } + + // ================================================================================ + // 公開: 環境 + // ================================================================================ + + /// ヘッドレス Form1(SelfTestStart 済み.ハンドル生成済み) + public Form1 Form { get; private set; } + + /// このテスト専用の一時フォルダ(後始末で削除される) + public string WorkDir { get; private set; } + + /// 一時設定の保存先(File/SaveFolder. 配下) + public string SaveRoot { get; private set; } + + /// 読み込んだ一時 config.xml のパス + public string ConfigPath { get; private set; } + + /// 同梱 config.xml の File/SaveFolder(書き込んでいないことの確認用) + public string UserSaveFolder { get; private set; } + + /// 開始からの経過時間 + public TimeSpan Elapsed => _elapsed.Elapsed; + + /// テスト本体の上限までの残り時間(0 未満にはならない) + public TimeSpan Remaining { + get { + var r = _timeout - _elapsed.Elapsed; + return r > TimeSpan.Zero ? r : TimeSpan.Zero; + } + } + + // ================================================================================ + // 公開: 待機 + // ================================================================================ + + /// + /// 条件が成立するまでメッセージを処理して待つ.成立しなければ を投げる. + /// 待機中に UI スレッドで未処理例外が起きた場合はその例外(を内包した例外)を投げる. + /// + /// 成立を待つ条件(STA スレッドで評価する) + /// 待つ内容(失敗メッセージに使う.例: "保存スレッドの終了") + /// 上限(省略時はテスト本体の残り時間) + public void PumpUntil(Func 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; + } + } + + /// 指定時間だけメッセージを処理する(タイマー駆動の表示更新等を反映させる).残り時間を超えては待たない + 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); + } + + // ================================================================================ + // 公開: カメラ・資源 + // ================================================================================ + + /// + /// カメラを後始末の対象に登録する(終了時にプレビュー停止 → 保存スレッド終了待ち → Disconnect). + /// テスト内で CameraBase を派生させたカメラもこれで登録する. + /// + public T Track(T camera) where T : CameraBase { + if (camera != null) _cameras.Add(camera); + return camera; + } + + /// 資源を後始末で解放する対象に登録する(Form1 の破棄後,登録の逆順に Dispose) + public T AddDisposable(T disposable) where T : IDisposable { + if (disposable != null) _disposables.Add(disposable); + return disposable; + } + + /// + /// 合成の舌診チャート(SelfTestChart.色票は参照値 tcc_srgb を基にする)のフレームを返す生成関数. + /// 呼び出しごとに新しい Mat(所有権は呼び出し側=偽カメラ)を返す.元のチャート画像はハーネスが解放する. + /// + public Func ChartFrameFactory() { + if (_chartFrameFactory == null) { + var tccSrgb = LoadTccSrgb(); + var chart = AddDisposable(SelfTestChart.CreateChartFrame(tccSrgb)); + _chartFrameFactory = i => chart.Clone(); + } + return _chartFrameFactory; + } + + /// 参照値 tcc_srgb.csv(24x3)を読み込む(後始末で解放される) + public Mat LoadTccSrgb() { + var path = Config.GetString("File/TccSrgbRef"); + var m = IoUtil.LoadMatFromCsv(path); + if (m == null) throw new InvalidOperationException($"参照ファイルを読み込めません: {path}"); + return AddDisposable(m); + } + + /// 合成チャートを流す偽カメラを生成・登録し,接続(BootCheck)する + public SelfTestCamera CreateChartCamera() { + return CreateCamera(ChartFrameFactory()); + } + + /// 任意のフレームを流す偽カメラを生成・登録し,接続(BootCheck)する + /// フレーム番号から新しい Mat を返す関数(戻り値の所有権は偽カメラへ移る) + public SelfTestCamera CreateCamera(Func frameFactory) { + return Connect(Track(new SelfTestCamera(Form, frameFactory))); + } + + /// カメラを接続する.失敗したら ErrorMsg を含む例外を投げる + public T Connect(T camera) where T : CameraBase { + if (!camera.Connect()) throw new InvalidOperationException($"カメラの接続に失敗しました: {camera.ErrorMsg}"); + return camera; + } + + /// + /// 偽カメラのプレビューを流して自動校正(チャート検出 → 固定判定 → ホワイトバランス → 変換行列算出)を完了させる. + /// 完了しなければ例外を投げる.完了時点で撮影ボタンは有効化されている(CalcTcc → EnableShots). + /// + 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, "校正完了後の撮影ボタン有効化"); + } + + /// + /// 撮影の保存スレッド(SaveThread)の終了を待つ.終了時点で SaveThread からの通知 + /// (ShowMessage・ShowError・EnableShots の Invoke)は処理済みである. + /// + 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(); + Func 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}"); + } + } + + /// 同梱 config.xml・参照 CSV のあるフォルダ(リポジトリの TIASshot/.読むだけ) + 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 diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj index 504bc4a..261d935 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -76,6 +76,7 @@ + @@ -86,9 +87,11 @@ + + diff --git a/TIASshot/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs index 55b0474..a44c352 100644 --- a/TIASshot/Cameras/CameraBase.cs +++ b/TIASshot/Cameras/CameraBase.cs @@ -260,7 +260,7 @@ var filename = Config.GetString("File/RgbImage"); filename = filename.Replace("{NO}", $"{idx + 1:0000}"); - IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, filename), img); + SaveImageOrThrow(IoUtil.CombineSaveFilePath(_saveFolder, filename), img); foreach (var channel in _colorCorrector.Channels) { // ConvertImage は src を in-place で CV_64FC3 に変換するため、 @@ -271,12 +271,27 @@ using (var converted = _colorCorrector.ConvertImage(imgClone, channel)) { filename = GetFilenameWithChannel("File/SrgbImage", channel); filename = filename.Replace("{NO}", $"{idx + 1:0000}"); - IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, filename), converted); + SaveImageOrThrow(IoUtil.CombineSaveFilePath(_saveFolder, filename), converted); } } } /// + /// 撮影画像を保存し,失敗(IoUtil.SaveImage が false)なら IOException を投げる(SaveImages 専用). + /// Cv2.ImWrite は保存失敗(ディスク満杯・権限・同名フォルダ等)で例外を出さず false を返すだけのため, + /// 戻り値を無視すると保存できていない画像が「全ての画像保存完了」と表示される. + /// 例外にして SaveThread の catch → finally(DrainShots → NotifySaveFailed → RestoreShotButtons)の + /// 失敗経路に乗せ,ユーザーへ保存失敗を通知する. + /// + /// 保存先ファイルパス + /// 保存する画像 + private static void SaveImageOrThrow(string path, Mat img) { + if (!IoUtil.SaveImage(path, img)) { + throw new IOException($"ファイル({path})の書き込みに失敗しました."); + } + } + + /// /// 画像保存スレッド処理. /// バックグラウンドスレッドの未処理例外は .NET Framework 4.8 ではプロセスを即終了させるため, /// ここに例外境界を置き,保存失敗(ディスク満杯・パス不正・OpenCV 例外等)でもアプリを継続させる. diff --git a/TIASshot/SelfTest/SelfTestCamera.cs b/TIASshot/SelfTest/SelfTestCamera.cs index 93d9ba9..0098e76 100644 --- a/TIASshot/SelfTest/SelfTestCamera.cs +++ b/TIASshot/SelfTest/SelfTestCamera.cs @@ -16,8 +16,10 @@ /// CameraBase の実コード(BootCheck・ProcessCalibrationFrame・DetectChart・CalcTcc・ /// ShotOne → RunShotLoop → SaveThread → SaveImages)をそのまま通すことを目的とし, /// 本クラスで代替するのはカメラ固有部分(フレーム生成・ゲイン反映・撮影開始/終了)のみ. + /// 結合ロジックテスト(層②)で失敗を注入できるよう sealed にしない(テスト側の派生で + /// CaptureFrame・BeginShot 等を上書きして例外を投げさせる等.本番コードの挙動には影響しない). /// - internal sealed class SelfTestCamera : CameraBase { + internal class SelfTestCamera : CameraBase { // 合成フレームの生成元(呼び出しごとに新しい Mat を返す.所有権は本クラスへ移る) private readonly Func _frameFactory; diff --git a/TIASshot/SelfTest/SelfTestConfig.cs b/TIASshot/SelfTest/SelfTestConfig.cs new file mode 100644 index 0000000..5c27286 --- /dev/null +++ b/TIASshot/SelfTest/SelfTestConfig.cs @@ -0,0 +1,94 @@ +#if SELFTEST +using System.Collections.Generic; +using System.IO; +using System.Xml; + +namespace TIASshot { + + /// + /// セルフテスト・結合ロジックテスト(層②)用の一時設定ファイルを作る(SELFTEST 限定). + /// SelfTestRunner と TIASshot.Tests の共通ハーネスの両方から使う. + /// 同梱 config.xml を複製し,保存先(File/SaveFolder)を作業フォルダ配下に,参照 CSV を基準フォルダの絶対パスに, + /// 効果音を存在しないパス(再生しない)に差し替えて作業フォルダへ書き出し,Config に読み込む. + /// 元の config.xml は変更しない. + /// + internal static class SelfTestConfig { + + /// 作業フォルダに書き出す一時設定ファイルの名前 + public const string FileName = "config.selftest.xml"; + + /// 作業フォルダ内の保存先フォルダの名前(File/SaveFolder の差し替え先) + public const string SaveFolderName = "data"; + + /// 準備した一時設定の情報 + internal sealed class Prepared { + /// 書き出した一時設定ファイルのパス + public string ConfigPath; + /// 一時設定の File/SaveFolder(作業フォルダ配下) + public string SaveRoot; + /// 元の config.xml の File/SaveFolder(書き込まないことの確認用) + public string UserSaveFolder; + } + + /// + /// 一時設定ファイルを作り,Config.Load で読み込む. + /// + /// 複製元の config.xml のパス(読むだけ) + /// 相対パスで書かれた参照 CSV(File/TccSrgbRef・File/TccXyzRef)の基準フォルダ + /// 作業フォルダ(無ければ作る.一時設定と保存先はこの配下に置く) + /// + /// 上書きする設定(キーは "Calib/Frames" のような Config のパス,値は InnerText).null 可. + /// 値が null の項目は要素ごと削除する(設定欠落の注入用).標準の差し替えより後に適用する. + /// 存在しないキーに値を設定しようとした場合は InvalidDataException(キーの打ち間違いの検出). + /// + /// 準備した一時設定の情報 + /// ファイル・フォルダの読み書きに失敗した場合 + /// 複製元が XML として読めない場合 + /// 必要な要素が無い場合 + public static Prepared Prepare(string sourceConfig, string refBaseDir, string workDir, + IDictionary overrides = null) { + Directory.CreateDirectory(workDir); + var doc = new XmlDocument(); + doc.Load(sourceConfig); + + var result = new Prepared { + UserSaveFolder = GetNode(doc, "File/SaveFolder").InnerText, + SaveRoot = Path.Combine(workDir, SaveFolderName), + }; + GetNode(doc, "File/SaveFolder").InnerText = result.SaveRoot; + foreach (var key in new[] { "File/TccSrgbRef", "File/TccXyzRef" }) { + var node = GetNode(doc, key); + if (!Path.IsPathRooted(node.InnerText)) node.InnerText = Path.Combine(refBaseDir, node.InnerText); + } + var sounds = doc.SelectSingleNode("//Config/Sound"); + if (sounds != null) { + foreach (XmlNode node in sounds.ChildNodes) { + if (node.NodeType == XmlNodeType.Element) node.InnerText = Path.Combine(workDir, "selftest-no-sound.wav"); + } + } + + if (overrides != null) { + foreach (var kv in overrides) { + var node = GetNode(doc, kv.Key); + if (kv.Value == null) { + node.ParentNode.RemoveChild(node); + } else { + node.InnerText = kv.Value; + } + } + } + + result.ConfigPath = Path.Combine(workDir, FileName); + doc.Save(result.ConfigPath); + Config.Load(result.ConfigPath); + return result; + } + + private static XmlNode GetNode(XmlDocument doc, string path) { + var node = doc.SelectSingleNode($"//Config/{path}"); + if (node == null) throw new InvalidDataException($"config.xml に {path} がありません"); + return node; + } + } +} +#endif diff --git a/TIASshot/SelfTest/SelfTestPump.cs b/TIASshot/SelfTest/SelfTestPump.cs new file mode 100644 index 0000000..701a05d --- /dev/null +++ b/TIASshot/SelfTest/SelfTestPump.cs @@ -0,0 +1,54 @@ +#if SELFTEST +using System; +using System.Diagnostics; +using System.Threading; +using System.Windows.Forms; + +namespace TIASshot { + + /// + /// UI スレッドのメッセージを回しながら待つ処理(SELFTEST 限定). + /// SelfTestRunner と TIASshot.Tests の共通ハーネス(層②)の両方から使う. + /// ワーカースレッドからの Control.Invoke(ShowMessage / EnableShots 等)やタイマーの Tick は + /// UI スレッドがメッセージを処理しないと実行されないため,Join や Sleep だけで待つと相互待機でハングする. + /// 必ずフォームを生成したスレッド(STA)から呼ぶこと. + /// + internal static class SelfTestPump { + + // 1 回のメッセージ処理ごとの休止(ms)(CPU を占有しないため.応答性を損なわない短さにする) + private const int SleepMs = 2; + + /// + /// 条件が成立するまでメッセージを処理して待つ. + /// + /// 成立を待つ条件(UI スレッドで評価する) + /// 待機の上限 + /// メッセージ処理ごとに呼ぶ処理(null 可.UI 例外の再送出等に使う) + /// 上限内に成立したら true,タイムアウトしたら false + public static bool PumpUntil(Func condition, TimeSpan timeout, Action afterEachPump = null) { + var sw = Stopwatch.StartNew(); + while (!condition()) { + if (sw.Elapsed > timeout) return false; + Application.DoEvents(); + afterEachPump?.Invoke(); + Thread.Sleep(SleepMs); + } + return true; + } + + /// + /// 指定時間だけメッセージを処理し続ける(タイマー駆動の表示更新等を反映させるため). + /// + /// 処理し続ける時間 + /// メッセージ処理ごとに呼ぶ処理(null 可) + public static void PumpFor(TimeSpan duration, Action afterEachPump = null) { + var sw = Stopwatch.StartNew(); + while (sw.Elapsed < duration) { + Application.DoEvents(); + afterEachPump?.Invoke(); + Thread.Sleep(SleepMs); + } + } + } +} +#endif diff --git a/TIASshot/SelfTest/SelfTestRunner.cs b/TIASshot/SelfTest/SelfTestRunner.cs index 00c11ef..c9139fe 100644 --- a/TIASshot/SelfTest/SelfTestRunner.cs +++ b/TIASshot/SelfTest/SelfTestRunner.cs @@ -259,25 +259,6 @@ return CameraSdkAssemblyPrefixes.Any(p => simpleName.StartsWith(p, StringComparison.OrdinalIgnoreCase)); } - /// UI スレッドのメッセージを回しながら条件成立を待つ(Invoke・タイマー Tick を処理させるため) - private static bool PumpUntil(Func condition, TimeSpan timeout) { - var sw = Stopwatch.StartNew(); - while (!condition()) { - if (sw.Elapsed > timeout) return false; - Application.DoEvents(); - Thread.Sleep(2); - } - return true; - } - - private static void PumpFor(TimeSpan duration) { - var sw = Stopwatch.StartNew(); - while (sw.Elapsed < duration) { - Application.DoEvents(); - Thread.Sleep(2); - } - } - /// /// セルフテスト用の一時設定を作り,Config に読み込む.同梱 config.xml を複製し, /// 保存先(File/SaveFolder)を作業フォルダに,参照 CSV を実行ファイル基準の絶対パスに, @@ -286,41 +267,17 @@ /// 失敗時はその理由,成功時は null private static string PrepareConfig(Context ctx) { try { - Directory.CreateDirectory(ctx.WorkDir); - var src = Path.Combine(ctx.BaseDir, "config.xml"); - var doc = new XmlDocument(); - doc.Load(src); - - ctx.UserSaveFolder = GetNode(doc, "File/SaveFolder").InnerText; - ctx.SaveRoot = Path.Combine(ctx.WorkDir, "data"); - GetNode(doc, "File/SaveFolder").InnerText = ctx.SaveRoot; - foreach (var key in new[] { "File/TccSrgbRef", "File/TccXyzRef" }) { - var node = GetNode(doc, key); - if (!Path.IsPathRooted(node.InnerText)) node.InnerText = Path.Combine(ctx.BaseDir, node.InnerText); - } - var sounds = doc.SelectSingleNode("//Config/Sound"); - if (sounds != null) { - foreach (XmlNode node in sounds.ChildNodes) { - if (node.NodeType == XmlNodeType.Element) node.InnerText = Path.Combine(ctx.WorkDir, "selftest-no-sound.wav"); - } - } - - var dst = Path.Combine(ctx.WorkDir, "config.selftest.xml"); - doc.Save(dst); - Config.Load(dst); - if (!Config.IsLoaded()) return $"一時設定ファイルを読み込めません: {dst}"; + // 一時設定の作成・読み込みは層②のテストハーネスと共通(SelfTestConfig) + var prepared = SelfTestConfig.Prepare(Path.Combine(ctx.BaseDir, "config.xml"), ctx.BaseDir, ctx.WorkDir); + ctx.UserSaveFolder = prepared.UserSaveFolder; + ctx.SaveRoot = prepared.SaveRoot; + if (!Config.IsLoaded()) return $"一時設定ファイルを読み込めません: {prepared.ConfigPath}"; return null; } catch (Exception ex) { return $"一時設定の準備に失敗しました: {SelfTestReport.DescribeException(ex)}"; } } - private static XmlNode GetNode(XmlDocument doc, string path) { - var node = doc.SelectSingleNode($"//Config/{path}"); - if (node == null) throw new InvalidDataException($"config.xml に {path} がありません"); - return node; - } - private static void CleanupWorkDir(SelfTestReport report, string workDir, bool keep) { if (!Directory.Exists(workDir)) return; if (keep) { @@ -606,14 +563,14 @@ "偽カメラ接続(CameraBase コンストラクタ・BootCheck): OK")) return; camera.StartPreview(frames, 30, false); - var finished = PumpUntil(() => !camera.IsPreviewRunning, PreviewTimeout); + var finished = SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, PreviewTimeout); if (!c.Expect(finished, $"プレビューが {PreviewTimeout.TotalSeconds:0} 秒以内に終わりません({camera.FramesDelivered}/{frames} フレーム)")) return; if (camera.PreviewError != null) c.FailException("プレビューコールバックの例外", camera.PreviewError); c.Expect(camera.FramesDelivered == frames, $"コールバックへ渡したフレーム数が不正: {camera.FramesDelivered}/{frames}", $"コールバック → ProcessCalibrationFrame → Form1.ShowImage: {frames} フレーム"); // 本モニタはタイマー(約 33ms)駆動.最終フレームが反映されるまでメッセージを回す - PumpFor(TimeSpan.FromMilliseconds(300)); + SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(300)); monitor.SelfTestRefresh(); // 入力 Bitmap(カメラ側の二重バッファ)をすべて解放してから表示側を検査する. @@ -655,8 +612,8 @@ if (camera == null) return; camera.RequestStopPreview(); // プレビュースレッドは Invoke で UI スレッドを待つことがあるため,Join ではなくメッセージを回して待つ - PumpUntil(() => !camera.IsPreviewRunning, TimeSpan.FromSeconds(10)); - PumpUntil(() => !camera.IsSaveThreadStarted, TimeSpan.FromSeconds(10)); + SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, TimeSpan.FromSeconds(10)); + SelfTestPump.PumpUntil(() => !camera.IsSaveThreadStarted, TimeSpan.FromSeconds(10)); camera.Disconnect(); } @@ -685,7 +642,7 @@ // --- 自動校正(チャート検出 → 固定判定 → ホワイトバランス → 変換行列算出) --- var maxFrames = Config.GetInt("Calib/ChartSetCount") + Config.GetInt("Calib/Frames") + 60; camera.StartPreview(maxFrames, 0, true); - var finished = PumpUntil(() => !camera.IsPreviewRunning, CalibrationTimeout); + var finished = SelfTestPump.PumpUntil(() => !camera.IsPreviewRunning, CalibrationTimeout); if (!c.Expect(finished, $"校正が {CalibrationTimeout.TotalSeconds:0} 秒以内に終わりません({camera.FramesDelivered} フレーム)")) return; if (camera.PreviewError != null) { c.FailException("プレビュー(校正)中の例外", camera.PreviewError); @@ -693,7 +650,7 @@ } if (!c.Expect(camera.IsCalibrated, $"校正が完了しません({camera.FramesDelivered} フレーム.最後の表示: 「{form.SelfTestMessage}」.チャート検出の失敗が疑われる)")) return; - PumpFor(TimeSpan.FromMilliseconds(100)); // CalcTcc の ShowMessage / EnableShots(Invoke)を処理させる + SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(100)); // CalcTcc の ShowMessage / EnableShots(Invoke)を処理させる c.Info($"チャート検出・校正完了: {camera.FramesDelivered} フレーム,疑似ゲイン {camera.DescribeGain()},表示「{form.SelfTestMessage}」"); c.Expect(form.SelfTestShotsEnabled, "校正完了後に撮影ボタンが有効化されていません(CalcTcc → EnableShots)"); @@ -711,9 +668,9 @@ // --- 撮影 → 保存(ShotOne → RunShotLoop → SaveThread → SaveImages) --- var shotOk = Step(c, "撮影(CameraBase.ShotOne)", () => camera.ShotOne()); - var saved = PumpUntil(() => !camera.IsSaveThreadStarted, SaveTimeout); + var saved = SelfTestPump.PumpUntil(() => !camera.IsSaveThreadStarted, SaveTimeout); if (!c.Expect(saved, $"画像保存が {SaveTimeout.TotalSeconds:0} 秒以内に終わりません")) return; - PumpFor(TimeSpan.FromMilliseconds(100)); // SaveThread の ShowMessage / EnableShots(Invoke)を処理させる + SelfTestPump.PumpFor(TimeSpan.FromMilliseconds(100)); // SaveThread の ShowMessage / EnableShots(Invoke)を処理させる if (!shotOk) return; c.Expect(form.SelfTestLastError == null, $"エラー通知(ShowError)が発生: {form.SelfTestLastError}"); c.Expect(form.SelfTestShotsEnabled, "保存完了後に撮影ボタンが再有効化されていません(SaveThread → EnableShots(true))"); diff --git a/TIASshot/TIASshot.csproj b/TIASshot/TIASshot.csproj index 0ebe8b3..71a3cd3 100644 --- a/TIASshot/TIASshot.csproj +++ b/TIASshot/TIASshot.csproj @@ -167,6 +167,8 @@ + + diff --git a/TIASshot/UI/Form1.SelfTest.cs b/TIASshot/UI/Form1.SelfTest.cs index 4dcd702..c4803a2 100644 --- a/TIASshot/UI/Form1.SelfTest.cs +++ b/TIASshot/UI/Form1.SelfTest.cs @@ -1,4 +1,5 @@ #if SELFTEST +using System.Collections.Generic; using System.Diagnostics; using System.Drawing; using System.Windows.Forms; @@ -18,6 +19,10 @@ private bool _selfTestHeadless = false; // ヘッドレスモードで ShowError に渡されたメッセージ(最後の 1 件) private string _selfTestLastError = null; + // ヘッドレスモードで ShowError / ShowMessage に渡されたメッセージの履歴(古い順._selfTestLogLock で保護) + private readonly List _selfTestErrors = new List(); + private readonly List _selfTestMessages = new List(); + private readonly object _selfTestLogLock = new object(); /// /// ヘッドレス(非表示)セルフテスト用コンストラクタ(SELFTEST 限定). @@ -80,10 +85,58 @@ /// 撮影ボタンが有効か internal bool SelfTestShotsEnabled => btnShotOne.Enabled && btnShotMulti.Enabled; + /// ヘッドレスモードで ShowError に渡されたメッセージの履歴(古い順のコピー) + internal IReadOnlyList SelfTestErrors { + get { lock (_selfTestLogLock) return _selfTestErrors.ToArray(); } + } + + /// + /// ヘッドレスモードで ShowMessage に渡されたメッセージの履歴(古い順のコピー). + /// ステータス表示は上書きされるため,「保存失敗時に『全ての画像保存完了』を一度も表示しない」のような + /// 途中経過の検証に使う. + /// + internal IReadOnlyList SelfTestMessages { + get { lock (_selfTestLogLock) return _selfTestMessages.ToArray(); } + } + + /// ShowError・ShowMessage の記録(最後のエラー・履歴)を消去する + internal void SelfTestClearLog() { + lock (_selfTestLogLock) { + _selfTestLastError = null; + _selfTestErrors.Clear(); + _selfTestMessages.Clear(); + } + } + + /// データ名(txtDataName)を設定する(GetDataName の戻り値=保存フォルダ名の一部) + internal void SelfTestSetDataName(string dataName) { + txtDataName.Text = dataName; + } + + /// + /// 連続撮影の枚数・間隔の入力欄(txtNumMultiShots・txtMultiShotsInterval)を設定する. + /// 不正値の注入ができるよう文字列で受け取る(GetNumMultiShots / GetMultiShotsInterval の解釈に従う). + /// + internal void SelfTestSetMultiShots(string numShots, string intervalMs) { + txtNumMultiShots.Text = numShots; + txtMultiShotsInterval.Text = intervalMs; + } + /// ShowError のヘッドレス版(モーダルダイアログを出すと無人実行が止まるため記録のみ行う) private void SelfTestRecordError(string msg) { Debug.WriteLine($"[SELFTEST] ShowError: {msg}"); - _selfTestLastError = msg; + lock (_selfTestLogLock) { + _selfTestLastError = msg; + _selfTestErrors.Add(msg); + } + } + + /// ShowMessage の記録(ヘッドレスモードのみ.表示そのものは通常どおり行う) + private void SelfTestRecordMessage(string msg) { + if (!_selfTestHeadless) return; + lock (_selfTestLogLock) { + _selfTestMessages.Add(msg); + } } /// diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index d091a55..b717f2b 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -275,6 +275,9 @@ Invoke((MethodInvoker)delegate { ShowMessage(msg); }); return; } +#if SELFTEST + SelfTestRecordMessage(msg); +#endif txtMessage.Text = msg; txtMessage.Update(); } diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" index e277d85..c29a8e9 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -56,6 +56,17 @@ - ネイティブ DLL を x64 でロードするため `/Platform:x64` は必須. - Visual Studio 上ではテストエクスプローラーから実行できる(プラットフォームを x64 にすること). +カテゴリで絞り込んで実行する(開発中の確認用.判定には全件実行を使う). + +```powershell +# 層②(結合ロジック)のテストだけ実行する +& $vstest "TIASshot.Tests\bin\x64\Debug\TIASshot.Tests.dll" /Platform:x64 "/TestCaseFilter:TestCategory=Integration" +# メモリ量で判定するテストを除いて実行する(環境による不安定さを切り分けるとき) +& $vstest "TIASshot.Tests\bin\x64\Debug\TIASshot.Tests.dll" /Platform:x64 "/TestCaseFilter:TestCategory!=Memory" +``` + +- 層②のテストは `SELFTEST` フラグに依存するため,Debug 構成でのみコンパイル・実行される. + ## クリーン環境での検証 (Clean Verification) **ベースライン計測・マージ前検証・「テストが通ることの確認」は,必ず `bin` / `obj` を削除してから行う.** diff --git "a/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" "b/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" index 1c0b156..e181f4b 100644 --- "a/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" +++ "b/docs/03_PLAN/PLAN_05_\343\203\206\343\202\271\343\203\210\347\266\262\347\276\205\346\200\247\345\220\221\344\270\212\350\250\210\347\224\273.md" @@ -72,6 +72,11 @@ - ハーネスを使うテストが 3 回連続で安定して緑になる - 層②の各テストに 15 秒のタイムアウトが付いている - Release ビルドに影響しない(`SELFTEST` は Debug 系のみ) +- **状態: 完了(2026-10-06)** + - ハーネスは `TIASshot.Tests/IntegrationHarness.cs`.一時 config の作成と待機処理は `TIASshot/SelfTest/SelfTestConfig.cs`・`SelfTestPump.cs` に切り出し,セルフテストのランナーと共用する + - 試作テスト `CameraBaseSaveIntegrationTests`(保存成功・単発の保存失敗・連続撮影の途中の保存失敗)は 3 回連続で緑.保存失敗の 2 件は修正前のコードで赤になることを確認した.層② 3 件の所要時間は計約 5 秒 + - 失敗の注入は,偽カメラの派生で保存先と同名のフォルダを作り `Cv2.ImWrite` に false を返させる方法をとった(例外ではなく false になることを単体テストで固定している) + - 引き続き残る課題: `CalcTcc` の校正画像の保存失敗は「自動校正完了」と表示される(失敗時に校正済みとするかが仕様判断のため未修正) ### フェーズ 2: 層②の拡充 diff --git "a/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" "b/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" index f43d319..402c489 100644 --- "a/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" +++ "b/docs/06_TEST/TEST_01_\343\203\206\343\202\271\343\203\210\344\273\225\346\247\230.md" @@ -7,6 +7,7 @@ テストの種類: - ビルドテスト: コンパイルエラーがないことを確認する +- 結合ロジックテスト: 偽カメラとヘッドレス Form1 で,撮影・保存の流れと失敗時の通知を NUnit で自動確認する(`[Category("Integration")]`.方針は `.claude/rules/testing.md`,移行計画は [PLAN_05](../03_PLAN/PLAN_05_テスト網羅性向上計画.md)) - 回帰テスト: リファクタリング前後の出力が一致することを確認する - セルフテスト: 実機なしで依存ロード・プレビュー経路・校正〜保存を自動確認する(手動テストの前段) - 手動テスト: 実機を使って動作を目視確認する @@ -19,6 +20,18 @@ - **合格条件**: 終了コード 0(`deps`・`preview`・`calib-save` の 3 項目が PASS) - 代替していない範囲(実カメラ SDK との結合・実プレビュー表示・実チャートの検出性能・画像の見た目・長時間のメモリ/GDI)は,引き続き下記の手動テストで確認する +## 結合ロジックテスト (Integration Logic Tests) + +NUnit の全件実行に含まれる(ENV_04 の「テスト」.カテゴリで絞る場合は `TestCategory=Integration`).現在のケース: + +| テスト | 確認内容 | +| --- | --- | +| `CameraBaseSaveIntegrationTests`(単発・保存成功) | 撮影画像・sRGB 変換画像・Info.csv が保存され,「全ての画像保存完了」が表示され,撮影ボタンが再び有効になる | +| `CameraBaseSaveIntegrationTests`(単発・撮影画像の保存失敗) | 保存失敗が通知され,「全ての画像保存完了」が表示されず,撮影ボタンが再び有効になる | +| `CameraBaseSaveIntegrationTests`(連続 3 枚・2 枚目の保存失敗) | 同上 | + +- **合格条件**: 全件緑.新しいケースを追加したときは,取り込む前に 3 回連続で緑になることを確認する + ## ビルドテスト (Build Test) 全フェーズで実施する. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d3e2068..1934604 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -173,3 +173,8 @@ - 注意: 追加したリーク再現テストのうち 2 件は環境に依存しうる(OpenCV 内部の参照カウントのオフセット読み取り,プライベートメモリ増分のしきい値判定).OpenCvSharp の更新時や別マシンで落ちたら見直す(PLAN_05 で `[Category("Memory")]` に分ける予定). - 後段: 要対応のうち重大度「中」(保存失敗が成功扱い・終了時のハングや画像の取りこぼし・校正例外がコールバックへ素通り・起動時の設定エラー・光源ポート使用中)は PLAN_05 のフェーズ 1〜3 で対応する. + +- [x] **PLAN_05 フェーズ 1: 層②テストの共通ハーネスと保存失敗の修正** (2026-10-06): テストプロジェクトに WinForms 参照を足し,偽カメラ+ヘッドレス `Form1` を STA スレッドで動かす共通ハーネスを作った.最初の利用例として,画像の保存に失敗しても「全ての画像保存完了」と表示されていた不具合(`/auto-audit` 報告)を,修正前に赤になる結合テストで確認したうえで修正した. + - 設計判断: 一時 config の作成と DoEvents による待機はセルフテストのランナーから切り出し,ハーネスと共用した(2 か所で同じ待機処理を持たない).失敗の注入は,偽カメラの派生で保存先と同名のフォルダを作って `ImWrite` に false を返させる方法にした.保存フォルダ自体を消す案は Info.csv の作成も失敗させて別の経路に乗り,SaveThread との競合で結果も不安定になるため採らなかった. + - 失敗パターン: 修正を一時的に戻して赤を確認したあと,ファイルを書き戻すときに更新日時が古いまま残り,MSBuild が再コンパイルせず全体テストが誤って赤になった.ファイルを戻したら更新日時も更新するか,クリーンビルドしてから判定する. + - 後段: `CalcTcc` の校正画像の保存失敗(仕様判断待ち).tester・refactorer がビルドとテストを自分で実行できない問題は,テストランナー(`run_tests.py`)の追加で解消した(PR #55).