diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 14c5401..6d759f6 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -46,6 +46,7 @@ | 単体テスト | ピュアロジック層 | 自動(NUnit) | コミット前・`/implement` Phase 2 | | ビルドテスト | ソリューション全体 | 自動(MSBuild) | 全コミット前 | | 回帰テスト | 色補正・チャート検出の出力 | 半自動(基準データ比較) | リファクタ・修正の前後 | +| セルフテスト | 依存ロード・偽カメラでのプレビュー経路・合成チャートでの校正〜保存 | 自動(`--selftest`.`/verify`・`/implement` Phase 1b) | 依存更新・ハードウェア結合層の変更後,手動テストの前段 | | 手動テスト | 起動〜撮影〜保存の一連動作 | 手動(実機) | 機能追加・修正の完了時 | 回帰テスト・手動テストの具体手順は [TEST_01_テスト仕様](../../docs/06_TEST/TEST_01_テスト仕様.md) に記載する. @@ -73,7 +74,7 @@ | `UI/Form1`・`UI/PreviewMonitor` | WinForms UI スレッド・画面描画に依存 | | `LightSource` | 光源のシリアル通信(実機)に依存 | -※ ハードウェア結合層に手を入れた場合は,自動テストの代わりに**設計レビュー+実機回帰**で挙動を確認する.`/implement` の Tester フェーズでは,対象がハードウェア結合層の場合,無理にモックを作らず設計レビューで判断してよい. +※ ハードウェア結合層に手を入れた場合は,自動テストの代わりに**設計レビュー+実機回帰**で挙動を確認する.その前段として,実機なしで通せる範囲はセルフテスト(`python scripts/verify/run_selftest.py`.`.claude/verify-profile.md`)で確認できる.セルフテスト部品のうちピュアロジック(`SelfTestReport`・`SelfTestChart`・引数判定)は NUnit の対象(`#if SELFTEST`)で,偽カメラ・ヘッドレス Form1 の流れはセルフテストの実行自体で担保する.`/implement` の Tester フェーズでは,対象がハードウェア結合層の場合,無理にモックを作らず設計レビューで判断してよい. ## フレームワークと配置 (Framework & Layout) diff --git a/.claude/verify-profile.md b/.claude/verify-profile.md new file mode 100644 index 0000000..2f4d3ba --- /dev/null +++ b/.claude/verify-profile.md @@ -0,0 +1,127 @@ +# 検証プロファイル (Verify Profile) + +TIAS Shot(WinForms / .NET Framework 4.8 / x64 の舌診撮影ソフト)の検証プロファイル.verifier は本ファイルだけを頼りに検証する. + +経緯: 2026-09-18 時点では「verifier はハードウェア結合層(カメラ・UI・スレッド)を担保できない」としてプロファイルを作らなかった.その後,ヘッドレスのセルフテストモード(`TIASshot.exe --selftest`,`SELFTEST` ビルドフラグ)で起動時の依存解決・プレビュー経路・校正〜保存の一部を実機なしで自動確認できるようになったため,本プロファイルを作成した. + +## 種別・検証手段 (Target & Method) + +- デスクトップアプリ(WinForms).**画面操作・カメラ操作はしない**. +- ランナー `scripts/verify/run_selftest.py` が,クリーンビルド(`Debug|x64`)→ 既存テスト(NUnit)→ `TIASshot.exe --selftest` を実行し,終了コードと結果ファイルで判定する. +- セルフテストの 3 項目(実行ファイルに組み込み済み.verifier がシナリオを書く必要はない): + +| ID | 確認内容 | 実コードを通す範囲 | 代替している部分 | +| --- | --- | --- | --- | +| `deps` | 起動時にアセンブリ解決エラー(FileLoadException / FileNotFoundException)が出ない | `TIASshot.exe.config` のバインディングリダイレクトと bin の DLL バージョンの一致,本体参照の推移的ロード,bin 内の全管理 DLL のロード,OpenCvSharp(ネイティブ DLL・Mat 演算・`Mat.AsSpan`(System.Memory)・ArUco),`BitmapConverter` と `BitmapUtil.FastClone` の往復,`config.xml`・TCC CSV の読み込み,**`Form1` の実コンストラクタ**(`InitializeComponent`) | `Form1_Load`(カメラ・光源への接続)は実行しない | +| `preview` | プレビュー経路(カメラコールバック → UI 所有コピー(FastClone)→ 表示用 Bitmap)が動く | `CameraBase`(コンストラクタ・`BootCheck`・`ProcessCalibrationFrame`),`Form1.ShowImage`・`MainRefreshTimer_Tick`(実タイマー),`PreviewMonitor`(実コンストラクタ・`UpdateImage`・`RefreshTimer_Tick`).入力 Bitmap 解放後も表示コピーが有効であること(所有権モデル)を確認 | カメラは偽カメラ(`SelfTest/SelfTestCamera`.Lucam のコールバック手順を再現).フォームは非表示・`InitializeComponent` を呼ばないヘッドレス版コンストラクタ.副ディスプレイの存在は模擬 | +| `calib-save` | 校正〜保存が一通り動く | `ChartDetector`(ArUco 検出・固定判定・ホモグラフィ・マスク),`CameraBase.CalcTcc`・`ColorCorrector`(変換行列算出・CSV 保存),`CameraBase.ShotOne` → `RunShotLoop` → `SaveThread` → `SaveImages`(sRGB 変換画像の保存).保存物の読み戻し・行列の有限性・変換後の色票が参照値に戻ることを確認 | チャートは合成画像(ArUco と色票をコードで描画).ゲイン反映は偽カメラが画像へ乗算して模擬.保存先は一時フォルダ | + +## 検証対象の環境 (Target Environments) + +- ローカルの接続先: このマシンのみ(ビルド・実行ファイル・一時フォルダ).ネットワーク上のサービスは使わない(`--full` 時の `nuget restore` による nuget.org からのパッケージ取得のみ例外) +- ローカル以外で許可する環境: なし +- 接続を禁止する環境(拒否リスト): 実機カメラ(Lumenera / ImagingSource)・光源(シリアルポート)・本番の撮影データフォルダ(`config.xml` の `File/SaveFolder`,既定 `C:\TIAS_Data`).セルフテストはこれらに触れない設計であり,verifier も触れない + +## 実行コマンド (Run Command) + +リポジトリルートで実行する. + +| 用途 | コマンド | +| --- | --- | +| 通常(コード変更の確認) | `python scripts/verify/run_selftest.py` | +| 依存更新(NuGet パッケージ・バインディングリダイレクトの変更)の確認 | `python scripts/verify/run_selftest.py --full` | + +- ランナーの `--no-clean`(Debug 出力を消さない)・`--skip-tests`(既存テストを省略)は開発時の短縮用であり,**判定には使わない**(verifier は上表のコマンドだけを使う) +- `--full` は `packages` を退避して `nuget restore` からやり直す(`git clone` 直後と同じ状態.ENV_04「クリーン環境での検証」).復元に失敗した場合は退避した `packages` を自動で戻す.`packages` の退避(改名)自体に失敗した場合(Visual Studio が使用中等)は `packages` を変更せずに終了コード 2 で止まる +- 出力先: `.verify/selftest/run-<日時>/`(`.gitignore` 済み.実行ごとに別ディレクトリ) +- 出力されるもの: `summary.json`(各ステップの成否)・`build.log`・`test.log`・`restore.log`(`--full` 時)・`selftest-result.txt`(セルフテストの詳細.UTF-8 BOM 付き) +- ランナーの終了コード: `0`=全て合格,`1`=不合格あり(ビルド失敗・テスト失敗・セルフテスト失敗),`2`=前提条件を満たさない(→「検証不能」) +- セルフテスト単体の終了コード(`summary.json` の `selftest_exit_code`): `0`=全項目合格,`1`=不合格あり,`2`=セルフテスト自体の異常,`3`=タイムアウト(5 分.ハングの疑い) + +### 判定の読み方 + +- `selftest-result.txt` は項目ごとに `[PASS] : ...` / `[FAIL] : ...` の見出し行と,その下の詳細行(` - ` 情報,` WARN:` 参考,` FAIL:` 失敗理由)で構成され,最終行が `RESULT: PASS (3/3)` または `RESULT: FAIL (...)` になる +- `FAIL:` 行には例外型・メッセージ・対象アセンブリ名(`[assembly: ...]`)・Fusion ログの要点(`[fusion: ...]`)・スタックの先頭が入る.依存の問題はここで特定できる +- 判定は**終了コードと結果ファイル**で行う.`TIASshot.exe` の標準出力は OS のコードページ(cp932)で出るため,直接実行すると文字化けすることがある(ランナーは標準出力を使わない) + +## 前提条件と確認方法 (Preconditions) + +| 前提条件 | 確認方法 | 満たされない場合 | +| --- | --- | --- | +| Visual Studio 2022 または Build Tools(MSBuild・vstest)が導入済み | ランナーが `vswhere` で自動検出する(見つからなければ終了コード 2) | 検証不能とし,`docs/02_ENV/ENV_02_環境構築手順.md` に従った導入を人間に案内する | +| NuGet パッケージが復元済み(`packages/`) | `ls packages` | `python scripts/verify/run_selftest.py --full` を実行してよい(`nuget.exe` が無ければ検証不能とし `setup.ps1` の実行を人間に案内する) | +| IC Imaging Control 3.5 .NET Library が導入済み(ビルドに必要.実機カメラは不要) | ビルドの成否(`build.log` に `TIS.Imaging` の参照エラーが無いこと) | 検証不能とし,ENV_02 の SDK 導入手順を人間に案内する | +| `TIASshot.exe` やテストが実行中でない(Debug 出力を削除できる) | ランナーがクリーンするのは Debug 出力(`TIASshot/` と `TIASshot.Tests/` の `bin/x64/Debug`・`obj/x64/Debug`)のみで,`bin/x64/Release` 等ほかの構成の出力には触れない.削除に失敗すると終了コード 2 で止まる | 検証不能とし,人間にアプリ・Visual Studio のデバッグ実行の終了を依頼する(**自分でプロセスを止めない**) | + +## 認証方法・ロール (Auth & Roles) + +- 認証なし + +## テストデータの規約 (Test Data Rules) + +- セルフテストが書き込むのは次の 2 か所だけ: + - `%TEMP%\TIASshot-selftest\work-<日時>-\`(一時設定ファイル・校正データ・撮影画像).**合格時は自動削除**,不合格時は証拠として残し,結果ファイルの末尾に `work dir kept: <パス>` と出る + - `.verify/selftest/run-<日時>/`(ランナーのログと結果) +- 同梱の `config.xml` は変更しない(セルフテストは複製して保存先・参照パス・効果音を差し替えた一時設定を使う).本番の保存先(`C:\TIAS_Data` 等)には書き込まない +- 不合格時に残った `work-*` フォルダの有無とパスを,報告の「後始末」に書く(削除は人間の判断に任せてよい) + +## 待ち方・操作の癖 (Waiting & Selectors) + +- ランナーは同期実行で,完了まで待つだけでよい(通常 10〜30 秒.`--full` はパッケージ取得で数分) +- セルフテストはウィンドウを表示しない.画面の確認手段は無く,スクリーンショットも出ない +- `Release` 構成にはセルフテストが含まれない(`SELFTEST` フラグは Debug 系のみ).Release の exe に `--selftest` を渡すと通常起動(UI 表示・カメラ接続)になるため,実行しない + +## 既知のノイズ (Known Noise) + +- `deps` の `リソース系の AssemblyResolve n 件(サテライトリソースの探索.判定対象外)`: 日本語カルチャのサテライトリソース(`TIASshot.resources`)を探して見つからないのは通常動作 +- `deps` の `redirect System.ValueTuple → 4.0.3.0: bin に DLL なし`: .NET Framework 4.7.1 以降は mscorlib に統合されており bin に置かれないのが正常 +- カメラ SDK(`TIS.*`・`lumenera.*`)のロード失敗は `WARN:`(判定対象外).SDK 未導入の環境では出うる +- `preview` / `calib-save` の所要時間は環境で変わる(判定条件ではない) + +## 禁止操作 (Prohibited Operations) + +- `--selftest` を付けない `TIASshot.exe` の起動(UI が開き,実機カメラ・光源(シリアルポート)へ接続を試みる) +- `config.xml`(リポジトリ内・bin 内とも)や本番の保存先フォルダ(`C:\TIAS_Data` 等)の編集・削除 +- 自分が起動していないプロセスの停止(人間が起動した TIASshot・Visual Studio のデバッグ実行を止めない) +- `packages.bak-*` 以外のリポジトリ内ディレクトリの手動削除・改名(`packages` の退避と復元はランナーの `--full` だけが行う) +- ランナーを通さない独自のビルド・実行手順の組み立て(コマンドは本ファイルに書かれたものだけを使う) +- 失敗注入(bin 内 DLL の一時除去・`TIASshot.exe.config` の改ざん等)や `TIASshot.exe --selftest` の直接実行.セルフテストが依存の問題を検出できるかの確認が必要な場合は,人間が `docs/02_ENV/ENV_04_開発コマンド.md` の手順で行う(verifier には許可しない) + +## シナリオのテンプレート (Scenario Template) + +本プロジェクトでは **verifier がシナリオを書く必要はない**(確認項目は `TIASshot.exe --selftest` に組み込み済みで,引数で項目を選ぶ仕組みも無い).verifier の作業は次のとおり. + +```text +1. 変更内容に応じてコマンドを選ぶ + - NuGet パッケージ・App.config(バインディングリダイレクト)・csproj の参照を変えた → --full + - それ以外のコード変更 → 通常 +2. python scripts/verify/run_selftest.py [--full] を実行する +3. 出力ディレクトリの summary.json と selftest-result.txt を Read で開き, + - 各ステップ(clean / restore / build / tests / selftest)の成否 + - 各項目(deps / preview / calib-save)の [PASS]/[FAIL] と,FAIL: 行・WARN: 行の内容 + を確認する.PASS でも,今回の変更に関係する項目の詳細行(ロードされた DLL のバージョン等)を必ず見る +4. FAIL の場合は FAIL: 行(例外型・アセンブリ名・Fusion ログ)を証拠として報告する + (build.log / test.log の該当箇所も併記する) +``` + +変更がセルフテストの 3 項目で観測できない領域(下記「自動で確認できない範囲」)にある場合は,その部分を「未確認」として人間に回す. + +## 自動で確認できない範囲(人間が確認する項目)(Out of Scope) + +セルフテストが PASS でも,以下は確認していない.変更がこれらに関わる場合は「人間が確認すべき残り項目」に具体的に書く. + +- **実カメラ SDK との結合**: Lucam / IScam の接続・パラメータ設定・フレームコールバック・撮影(Snap・Sink 切り替え)・ROI 切り出し・白板撮影(`WHITEBOARD`) +- **実プレビュー表示**: 実ウィンドウへの描画・表示の滑らかさ・副ディスプレイでの表示位置・F9 トグル(`MEMMONITOR`) +- **実チャートの検出性能**: 実物の舌診チャートを実照明・実レンズで撮った画像での ArUco 検出・固定判定・マスク位置(合成チャートは理想条件) +- **画像の見た目**: 校正後の sRGB 画像の色・露出・ホワイトバランスが臨床的に妥当か(セルフテストは合成チャートでの数値一致のみ) +- **長時間のメモリ / GDI**: 長時間プレビュー・連続撮影でのメモリリーク・GDI ハンドル枯渇(`docs/06_TEST/TEST_03_メモリ修正効果計測.md` の手順) +- 光源(シリアル通信)の制御,効果音の再生,エラー時のダイアログ表示(セルフテストでは記録のみに差し替えている) +- `Release` 構成の実行(セルフテストを含まないため,起動確認は人間が行う) + +## 実行基盤の要件 (Runner Requirements) + +- 決定的な処理(ツールの検出・bin/obj の削除・パッケージ復元・ビルド・テスト・セルフテスト実行・結果の集計)はランナー `scripts/verify/run_selftest.py` が行う.verifier は判断(結果の読み取り・報告)のみ行う +- 実行ごとに `.verify/selftest/run-<日時>/` へ出力し,前回の証拠を上書きしない +- ランナーは自分が起動したプロセス(MSBuild・vstest・TIASshot.exe)以外を停止しない.セルフテストは内部のウォッチドッグ(5 分)でハング時も終了コード 3 で終わる +- 前提を満たさない場合は破壊的な復旧を試みず,終了コード 2 と理由を出して終了する +- セルフテストの実装は `TIASshot/SelfTest/` と `TIASshot/UI/*.SelfTest.cs`(`#if SELFTEST`),ビルドフラグは `docs/02_ENV/ENV_06_ビルド構成とビルドフラグ.md` を参照する diff --git a/.gitignore b/.gitignore index 7b7507d..dc21343 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ # リファクタリング回帰比較用の基準データ(実機出力・一時データ) _基準データ_refactor前/ +# 検証ランナーの出力(scripts/verify/run_selftest.py.ログ・セルフテスト結果) +.verify/ + # --- from template (sync-template) --- .claude/commit-context.md .claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 4b75c57..5bdb8c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: 2 回目の `/deps-update`(#44 マージ)と NUnit 4.6.1 への移行(#27 の追従).#31(ArUco API 移行)は実機確認待ちの PR #36 として引き続き保留中 +最新: ヘッドレスのセルフテスト(`--selftest`)と検証プロファイルを導入し,`/verify`・Phase 1b で依存ロード・プレビュー経路・校正〜保存を自動確認できるようにした.#31(ArUco API 移行)は実機確認待ちの PR #36 として引き続き保留中 ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git a/TIASshot.Tests/SelfTestChartTests.cs b/TIASshot.Tests/SelfTestChartTests.cs new file mode 100644 index 0000000..4d29ac5 --- /dev/null +++ b/TIASshot.Tests/SelfTestChartTests.cs @@ -0,0 +1,302 @@ +#if SELFTEST +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; +using OpenCvSharp; +using OpenCvSharp.Aruco; + +namespace TIASshot.Tests { + /// + /// セルフテスト用の合成チャート(SelfTestChart)のテスト. + /// + /// 仕様(SelfTestChart のクラス仕様・verify-profile の calib-save 行): + /// - ChartDetector のチャート座標系(810x1545,ArUco Dict4X4_50 の ID 40 が上・41 が下,24 色票)どおりに描く + /// - 色票の色は参照値をチャネルごとの 1 次式で歪めた「カメラ色」 + /// - 生成は決定的(同じ入力から同じ画像).実物の ChartDetector で検出でき,マスク位置が色票 ROI と一致する + /// - プレビュー用の単色フレームはフレーム番号で色が変わる(どのフレームのコピーかを画素値で判別できる) + /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる. + /// + [TestFixture] + public class SelfTestChartTests { + + private Mat _tcc; + + [SetUp] + public void SetUp() { + _tcc = CreateTcc(); + } + + [TearDown] + public void TearDown() { + _tcc?.Dispose(); + } + + // --------------------------------------------------------------- + // 生成画像の形式と決定性 + // --------------------------------------------------------------- + + [Test] + public void CreateChartFrame_フレームサイズと型_定数どおりのBGR3ch() { + using (var frame = SelfTestChart.CreateChartFrame(_tcc)) { + Assert.That(frame.Width, Is.EqualTo(SelfTestChart.FrameWidth)); + Assert.That(frame.Height, Is.EqualTo(SelfTestChart.FrameHeight)); + Assert.That(frame.Type(), Is.EqualTo(MatType.CV_8UC3)); + } + } + + [Test] + public void CreateChartFrame_同じ入力で2回生成_全画素一致() { + using (var a = SelfTestChart.CreateChartFrame(_tcc)) + using (var b = SelfTestChart.CreateChartFrame(_tcc)) + using (var diff = new Mat()) { + Cv2.Absdiff(a, b, diff); + using (var gray = diff.Reshape(1)) { + Assert.That(Cv2.CountNonZero(gray), Is.EqualTo(0)); + } + } + } + + [Test] + public void CreateChartFrame_チャートがフレーム内に収まる() { + Assert.That(SelfTestChart.OffsetX, Is.GreaterThanOrEqualTo(0)); + Assert.That(SelfTestChart.OffsetY, Is.GreaterThanOrEqualTo(0)); + Assert.That(SelfTestChart.OffsetX + SelfTestChart.ChartWidth, Is.LessThanOrEqualTo(SelfTestChart.FrameWidth)); + Assert.That(SelfTestChart.OffsetY + SelfTestChart.ChartHeight, Is.LessThanOrEqualTo(SelfTestChart.FrameHeight)); + } + + [Test] + public void ChartSize_ChartDetectorの座標系810x1545() { + Assert.That(SelfTestChart.ChartWidth, Is.EqualTo(810)); + Assert.That(SelfTestChart.ChartHeight, Is.EqualTo(1545)); + } + + // --------------------------------------------------------------- + // マーカー配置 + // --------------------------------------------------------------- + + [Test] + public void CreateChartFrame_ArUco検出_ID40と41が1個ずつ見つかる() { + var markers = DetectMarkers(); + Assert.That(markers.Keys.OrderBy(x => x).ToArray(), Is.EqualTo(new[] { 40, 41 })); + } + + [Test] + public void CreateChartFrame_ArUco検出_ID40が上でID41が下() { + var markers = DetectMarkers(); + Assume.That(markers.ContainsKey(40) && markers.ContainsKey(41), Is.True); + Assert.That(markers[40][3].Y, Is.LessThan(markers[41][3].Y)); + } + + [TestCase(40, 345, 130)] + [TestCase(41, 345, 1200)] + public void CreateChartFrame_ArUco検出_マーカー左上がチャート座標どおり(int id, int chartX, int chartY) { + var markers = DetectMarkers(); + Assume.That(markers.ContainsKey(id), Is.True); + var topLeft = markers[id][0]; + Assert.That(topLeft.X, Is.EqualTo(SelfTestChart.OffsetX + chartX).Within(1.5)); + Assert.That(topLeft.Y, Is.EqualTo(SelfTestChart.OffsetY + chartY).Within(1.5)); + } + + [TestCase(40)] + [TestCase(41)] + public void CreateChartFrame_ArUco検出_マーカーの一辺が120px(int id) { + var markers = DetectMarkers(); + Assume.That(markers.ContainsKey(id), Is.True); + var c = markers[id]; + Assert.That(c[1].X - c[0].X, Is.EqualTo(120).Within(2.0)); + Assert.That(c[3].Y - c[0].Y, Is.EqualTo(120).Within(2.0)); + } + + // --------------------------------------------------------------- + // 色票の配置と色 + // --------------------------------------------------------------- + + [Test] + public void PatchRoi_24個_すべてチャート内に収まる() { + var chart = new Rect(SelfTestChart.OffsetX, SelfTestChart.OffsetY, SelfTestChart.ChartWidth, SelfTestChart.ChartHeight); + for (var i = 0; i < 24; i++) { + var roi = SelfTestChart.PatchRoi(i); + Assert.That((roi & chart) == roi, Is.True, $"色票 {i} の ROI {roi} がチャート内にあること"); + } + } + + [Test] + public void PatchRoi_24個_互いに重ならない() { + var rois = Enumerable.Range(0, 24).Select(SelfTestChart.PatchRoi).ToArray(); + for (var i = 0; i < 24; i++) { + for (var j = i + 1; j < 24; j++) { + Assert.That((rois[i] & rois[j]).Width * (rois[i] & rois[j]).Height, Is.EqualTo(0), $"色票 {i} と {j}"); + } + } + } + + [Test] + public void PatchRoi_ArUcoマーカーと重ならない() { + var m40 = new Rect(SelfTestChart.OffsetX + 345, SelfTestChart.OffsetY + 130, 120, 120); + var m41 = new Rect(SelfTestChart.OffsetX + 345, SelfTestChart.OffsetY + 1200, 120, 120); + for (var i = 0; i < 24; i++) { + var roi = SelfTestChart.PatchRoi(i); + Assert.That((roi & m40).Width * (roi & m40).Height, Is.EqualTo(0), $"色票 {i} と ID40"); + Assert.That((roi & m41).Width * (roi & m41).Height, Is.EqualTo(0), $"色票 {i} と ID41"); + } + } + + [Test] + public void CreateChartFrame_各色票ROIの全画素_カメラ色で塗られている() { + using (var frame = SelfTestChart.CreateChartFrame(_tcc)) { + for (var i = 0; i < 24; i++) { + var expected = SelfTestChart.CameraColor(_tcc, i); + using (var roi = new Mat(frame, SelfTestChart.PatchRoi(i))) { + Cv2.MeanStdDev(roi, out var mean, out var stddev); + for (var ch = 0; ch < 3; ch++) { + Assert.That(mean[ch], Is.EqualTo(Math.Round(expected[ch])).Within(0.51), $"色票 {i} ch{ch} の平均"); + Assert.That(stddev[ch], Is.EqualTo(0).Within(1e-9), $"色票 {i} ch{ch} は単色"); + } + } + } + } + } + + [Test] + public void CameraColor_チャネルごとに1次式_等間隔の入力で等間隔の出力() { + using (var tcc = new Mat(3, 3, MatType.CV_64FC1)) { + for (var c = 0; c < 3; c++) { + tcc.Set(0, c, 20.0); + tcc.Set(1, c, 100.0); + tcc.Set(2, c, 180.0); + } + var v0 = SelfTestChart.CameraColor(tcc, 0); + var v1 = SelfTestChart.CameraColor(tcc, 1); + var v2 = SelfTestChart.CameraColor(tcc, 2); + for (var c = 0; c < 3; c++) { + Assert.That(v2[c] - v1[c], Is.EqualTo(v1[c] - v0[c]).Within(1e-9), $"ch{c} の線形性"); + Assert.That(v1[c], Is.GreaterThan(v0[c]), $"ch{c} は単調増加"); + } + } + } + + [Test] + public void CameraColor_参照値を歪める_恒等変換ではない() { + // 恒等だと色変換行列の検算にならない + var differs = false; + for (var i = 0; i < 24; i++) { + var v = SelfTestChart.CameraColor(_tcc, i); + for (var c = 0; c < 3; c++) { + if (Math.Abs(v[c] - _tcc.At(i, c)) > 1.0) differs = true; + } + } + Assert.That(differs, Is.True); + } + + [TestCase(0.0)] + [TestCase(255.0)] + [TestCase(-500.0)] + [TestCase(5000.0)] + public void CameraColor_画素値の範囲0から255に収まる(double refValue) { + using (var tcc = new Mat(1, 3, MatType.CV_64FC1, new Scalar(refValue))) { + var v = SelfTestChart.CameraColor(tcc, 0); + for (var c = 0; c < 3; c++) { + Assert.That(v[c], Is.InRange(0.0, 255.0), $"ch{c}"); + } + } + } + + // --------------------------------------------------------------- + // 実物の ChartDetector との整合 + // --------------------------------------------------------------- + + [Test] + public void ChartDetector_合成チャート_検出でき24枚のマスクが色票ROIと一致する() { + var cfg = Path.GetTempFileName() + ".xml"; + File.WriteAllText(cfg, + "" + + "4.03" + + "", System.Text.Encoding.UTF8); + List masks = null; + try { + Config.Load(cfg); + var detector = new ChartDetector(_ => { }); + using (var frame = SelfTestChart.CreateChartFrame(_tcc)) { + // 固定判定(同じ位置が ChartSetCount 回続く)を満たすまで同じフレームを与える + for (var n = 0; n < 10 && masks == null; n++) masks = detector.DetectChart(frame); + } + Assert.That(masks, Is.Not.Null, "合成チャートが検出されること"); + Assert.That(masks.Count, Is.EqualTo(24)); + for (var i = 0; i < 24; i++) { + var expected = SelfTestChart.PatchRoi(i); + var actual = Cv2.BoundingRect(masks[i]); + Assert.That(actual.X, Is.EqualTo(expected.X).Within(3), $"色票 {i} の X"); + Assert.That(actual.Y, Is.EqualTo(expected.Y).Within(3), $"色票 {i} の Y"); + Assert.That(actual.Width, Is.EqualTo(expected.Width).Within(3), $"色票 {i} の幅"); + Assert.That(actual.Height, Is.EqualTo(expected.Height).Within(3), $"色票 {i} の高さ"); + } + } finally { + if (masks != null) foreach (var m in masks) m.Dispose(); + if (File.Exists(cfg)) File.Delete(cfg); + } + } + + // --------------------------------------------------------------- + // プレビュー用の単色フレーム + // --------------------------------------------------------------- + + [Test] + public void PlainFrameColor_プレビュー12フレーム分_すべて異なる色() { + var colors = Enumerable.Range(0, 12).Select(SelfTestChart.PlainFrameColor) + .Select(s => $"{s.Val0},{s.Val1},{s.Val2}").ToList(); + Assert.That(colors, Is.Unique); + } + + [Test] + public void PlainFrameColor_画素値の範囲に収まる() { + for (var i = 0; i < 100; i++) { + var s = SelfTestChart.PlainFrameColor(i); + Assert.That(new[] { s.Val0, s.Val1, s.Val2 }, Is.All.InRange(0.0, 255.0), $"index {i}"); + } + } + + [Test] + public void CreatePlainFrame_指定サイズの単色BGR画像() { + using (var m = SelfTestChart.CreatePlainFrame(5, 64, 48)) { + Assert.That(m.Width, Is.EqualTo(64)); + Assert.That(m.Height, Is.EqualTo(48)); + Assert.That(m.Type(), Is.EqualTo(MatType.CV_8UC3)); + var expected = SelfTestChart.PlainFrameColor(5); + var px = m.At(47, 63); + Assert.That(new double[] { px.Item0, px.Item1, px.Item2 }, + Is.EqualTo(new[] { expected.Val0, expected.Val1, expected.Val2 }).Within(0.51)); + } + } + + // --------------------------------------------------------------- + // ヘルパ + // --------------------------------------------------------------- + + /// TCC の sRGB 参照値に相当する 24x3(B,G,R・CV_64F)の値(決定的な擬似値) + private static Mat CreateTcc() { + var m = new Mat(24, 3, MatType.CV_64FC1); + for (var i = 0; i < 24; i++) { + m.Set(i, 0, 30.0 + (i * 37) % 200); + m.Set(i, 1, 40.0 + (i * 53) % 190); + m.Set(i, 2, 50.0 + (i * 71) % 180); + } + return m; + } + + private Dictionary DetectMarkers() { + var result = new Dictionary(); + using (var frame = SelfTestChart.CreateChartFrame(_tcc)) + using (var dict = CvAruco.GetPredefinedDictionary(PredefinedDictionaryName.Dict4X4_50)) { + CvAruco.DetectMarkers(frame, dict, out var corners, out var ids, new DetectorParameters(), out _); + for (var i = 0; i < ids.Length; i++) { + Assert.That(result.ContainsKey(ids[i]), Is.False, $"ID {ids[i]} が重複検出された"); + result[ids[i]] = corners[i]; + } + } + return result; + } + } +} +#endif diff --git a/TIASshot.Tests/SelfTestReportTests.cs b/TIASshot.Tests/SelfTestReportTests.cs new file mode 100644 index 0000000..0d16d3e --- /dev/null +++ b/TIASshot.Tests/SelfTestReportTests.cs @@ -0,0 +1,267 @@ +#if SELFTEST +using System; +using System.IO; +using System.Reflection; +using System.Runtime.Serialization; +using System.Text; +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// SelfTestCheck(項目判定)と SelfTestReport(結果ファイル・例外要約)のテスト. + /// + /// 仕様: .claude/verify-profile.md「判定の読み方」 + /// - 項目ごとに `[PASS] <ID>: ...` / `[FAIL] <ID>: ...` の見出し行と,その下の詳細行 + /// (` - ` 情報,` WARN:` 参考,` FAIL:` 失敗理由) + /// - WARN は判定対象外(カメラ SDK 由来の失敗等).FAIL が 1 つでもあれば項目は FAIL + /// - 結果ファイルは UTF-8 BOM 付き + /// - `FAIL:` 行には例外型・メッセージ・対象アセンブリ名(`[assembly: ...]`)・Fusion ログの要点(`[fusion: ...]`)・スタックの先頭が入る + /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる. + /// + [TestFixture] + public class SelfTestReportTests { + + // --------------------------------------------------------------- + // SelfTestCheck: PASS/FAIL/WARN の集約 + // --------------------------------------------------------------- + + [Test] + public void Check_記録なし_PASS() { + var c = new SelfTestCheck("deps", "t"); + Assert.That(c.Status, Is.EqualTo(SelfTestStatus.Pass)); + } + + [Test] + public void Check_Infoのみ_PASS() { + var c = new SelfTestCheck("deps", "t"); + c.Info("情報"); + Assert.That(c.Status, Is.EqualTo(SelfTestStatus.Pass)); + } + + [Test] + public void Check_Warnのみ_判定に影響せずPASS() { + var c = new SelfTestCheck("deps", "t"); + c.Warn("カメラ SDK のロード失敗"); + c.Warn("もう一件"); + Assert.That(c.Status, Is.EqualTo(SelfTestStatus.Pass)); + Assert.That(c.WarnCount, Is.EqualTo(2)); + } + + [Test] + public void Check_Failが1件_FAIL() { + var c = new SelfTestCheck("deps", "t"); + c.Info("ok"); + c.Warn("warn"); + c.Fail("ng"); + Assert.That(c.Status, Is.EqualTo(SelfTestStatus.Fail)); + } + + [Test] + public void Check_詳細行の書式_情報WARNFAILの接頭辞() { + var c = new SelfTestCheck("deps", "t"); + c.Info("i"); + c.Warn("w"); + c.Fail("f"); + Assert.That(c.Lines, Is.EqualTo(new[] { " - i", " WARN: w", " FAIL: f" })); + } + + [Test] + public void Expect_条件偽_Failを記録して偽を返す() { + var c = new SelfTestCheck("deps", "t"); + var r = c.Expect(false, "失敗理由"); + Assert.That(r, Is.False); + Assert.That(c.Status, Is.EqualTo(SelfTestStatus.Fail)); + Assert.That(c.Lines, Has.Some.Contains("失敗理由")); + } + + [Test] + public void Expect_条件真_PASSのまま真を返しokメッセージを残す() { + var c = new SelfTestCheck("deps", "t"); + var r = c.Expect(true, "失敗理由", "成功"); + Assert.That(r, Is.True); + Assert.That(c.Status, Is.EqualTo(SelfTestStatus.Pass)); + Assert.That(c.Lines, Is.EqualTo(new[] { " - 成功" })); + } + + [Test] + public void FailException_例外型とメッセージを含むFAIL行() { + var c = new SelfTestCheck("deps", "t"); + c.FailException("ロード", new FileLoadException("load failed", "Foo, Version=1.0.0.0")); + Assert.That(c.Status, Is.EqualTo(SelfTestStatus.Fail)); + Assert.That(c.Lines[0], Does.StartWith(" FAIL: ロード: ")); + Assert.That(c.Lines[0], Does.Contain("System.IO.FileLoadException")); + Assert.That(c.Lines[0], Does.Contain("[assembly: Foo, Version=1.0.0.0]")); + } + + // --------------------------------------------------------------- + // DescribeException: 例外要約 + // --------------------------------------------------------------- + + [Test] + public void Describe_FileLoadException_型メッセージアセンブリ名を含む() { + var ex = new FileLoadException("Could not load file", "System.Memory, Version=4.0.1.2, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51"); + var s = SelfTestReport.DescribeException(ex); + Assert.That(s, Does.Contain("System.IO.FileLoadException: Could not load file")); + Assert.That(s, Does.Contain("[assembly: System.Memory, Version=4.0.1.2, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]")); + } + + [Test] + public void Describe_FileNotFoundException_アセンブリ名を含む() { + var s = SelfTestReport.DescribeException(new FileNotFoundException("not found", "OpenCvSharp.Extensions")); + Assert.That(s, Does.Contain("System.IO.FileNotFoundException: not found")); + Assert.That(s, Does.Contain("[assembly: OpenCvSharp.Extensions]")); + } + + [Test] + public void Describe_BadImageFormatException_アセンブリ名を含む() { + var s = SelfTestReport.DescribeException(new BadImageFormatException("bad image", "OpenCvSharpExtern.dll")); + Assert.That(s, Does.Contain("System.BadImageFormatException: bad image")); + Assert.That(s, Does.Contain("[assembly: OpenCvSharpExtern.dll]")); + } + + [Test] + public void Describe_ファイル名なし_assembly欄を出さない() { + var s = SelfTestReport.DescribeException(new FileLoadException("no name")); + Assert.That(s, Does.Not.Contain("[assembly:")); + } + + [Test] + public void Describe_通常の例外_型とメッセージのみ() { + var s = SelfTestReport.DescribeException(new InvalidOperationException("boom")); + Assert.That(s, Is.EqualTo("System.InvalidOperationException: boom")); + } + + [Test] + public void Describe_内部例外_外側から内側まで辿りアセンブリ名も出す() { + var inner = new FileLoadException("inner load", "System.Buffers, Version=4.0.3.0"); + var outer = new TypeInitializationException("TIASshot.Form1", inner); + var s = SelfTestReport.DescribeException(outer); + Assert.That(s, Does.StartWith("System.TypeInitializationException: ")); + Assert.That(s, Does.Contain(" <- inner: System.IO.FileLoadException: inner load")); + Assert.That(s, Does.Contain("[assembly: System.Buffers, Version=4.0.3.0]")); + } + + [Test] + public void Describe_深い内部例外の連鎖_例外を出さず外側を含む() { + Exception ex = new Exception("level-0"); + for (var i = 1; i <= 20; i++) ex = new Exception($"level-{i}", ex); + string s = null; + Assert.DoesNotThrow((Action)(() => s = SelfTestReport.DescribeException(ex))); + Assert.That(s, Does.StartWith("System.Exception: level-20")); + } + + [Test] + public void Describe_ReflectionTypeLoadException_LoaderExceptionsを含みnullは無視() { + var loaders = new Exception[] { + new FileNotFoundException("missing dep", "Foo"), + null, + new TypeLoadException("bad type"), + }; + var ex = new ReflectionTypeLoadException(new Type[] { null, null, null }, loaders, "types failed"); + var s = SelfTestReport.DescribeException(ex); + Assert.That(s, Does.Contain("[LoaderException: FileNotFoundException: missing dep]")); + Assert.That(s, Does.Contain("[LoaderException: TypeLoadException: bad type]")); + Assert.That(s, Does.Not.Contain("[LoaderException: ]")); + } + + [Test] + public void Describe_Fusionログ_参照リダイレクト失敗理由の行だけを抜粋する() { + var fusion = string.Join("\r\n", new[] { + "=== Pre-bind state information ===", + "LOG: DisplayName = System.Memory, Version=4.0.1.1", + "Calling assembly : OpenCvSharp, Version=1.0.0.0.", + "LOG: Redirect found in application configuration file: 4.0.1.1 redirected to 4.0.1.2.", + "LOG: Post-policy reference: System.Memory, Version=4.0.1.2", + "LOG: Attempting download of new URL file:///C:/app/System.Memory.DLL.", + "WRN: Comparing the assembly name resulted in the mismatch: Minor Version", + "ERR: Failed to complete setup of assembly (hr = 0x80131040). Probing terminated.", + }); + var ex = CreateFileLoadExceptionWithFusionLog("mismatch", "System.Memory, Version=4.0.1.2", fusion); + var s = SelfTestReport.DescribeException(ex); + Assert.That(s, Does.Contain("[fusion: ")); + Assert.That(s, Does.Contain("LOG: Post-policy reference: System.Memory, Version=4.0.1.2")); + Assert.That(s, Does.Contain("LOG: Redirect found in application configuration file")); + Assert.That(s, Does.Contain("WRN: Comparing the assembly name")); + Assert.That(s, Does.Contain("ERR: Failed to complete setup")); + Assert.That(s, Does.Contain("Calling assembly : OpenCvSharp")); + Assert.That(s, Does.Not.Contain("Attempting download")); + Assert.That(s, Does.Not.Contain("Pre-bind state")); + } + + [Test] + public void Describe_送出された例外_スタックの先頭を含む() { + Exception caught = null; + try { + throw new InvalidOperationException("thrown"); + } catch (InvalidOperationException ex) { + caught = ex; + } + var s = SelfTestReport.DescribeException(caught); + Assert.That(s, Does.Contain("[stack: ")); + Assert.That(s, Does.Contain(nameof(Describe_送出された例外_スタックの先頭を含む))); + } + + // --------------------------------------------------------------- + // SelfTestReport: 結果ファイル + // --------------------------------------------------------------- + + [Test] + public void Report_結果ファイル_UTF8BOM付きで見出し行と詳細行を書く() { + var dir = Path.Combine(Path.GetTempPath(), "TIASshot-tests-" + Guid.NewGuid().ToString("N"), "sub"); + var path = Path.Combine(dir, "result.txt"); + try { + var ok = new SelfTestCheck("deps", "依存"); + ok.Info("情報"); + var ng = new SelfTestCheck("preview", "プレビュー"); + ng.Fail("理由"); + using (var report = new SelfTestReport(path)) { + report.WriteCheck(ok); + report.WriteCheck(ng); + report.WriteLine("RESULT: FAIL (1/2 failed: preview)"); + } + + var bytes = File.ReadAllBytes(path); + Assert.That(bytes.Length, Is.GreaterThan(3)); + Assert.That(new[] { bytes[0], bytes[1], bytes[2] }, Is.EqualTo(new byte[] { 0xEF, 0xBB, 0xBF }), "UTF-8 BOM 付き"); + var lines = File.ReadAllLines(path, Encoding.UTF8); + Assert.That(lines, Is.EqualTo(new[] { + "[PASS] deps: 依存", + " - 情報", + "[FAIL] preview: プレビュー", + " FAIL: 理由", + "RESULT: FAIL (1/2 failed: preview)", + })); + } finally { + var root = Path.GetDirectoryName(dir); + if (Directory.Exists(root)) Directory.Delete(root, true); + } + } + + // --------------------------------------------------------------- + // ヘルパ + // --------------------------------------------------------------- + + /// + /// FusionLog 付きの FileLoadException を作る(FusionLog は公開コンストラクタで設定できないため, + /// 実物の直列化情報の FusionLog だけを差し替えて逆直列化コンストラクタで復元する). + /// + private static FileLoadException CreateFileLoadExceptionWithFusionLog(string message, string fileName, string fusionLog) { + var src = new FileLoadException(message, fileName); + var ctx = new StreamingContext(StreamingContextStates.All); + var orig = new SerializationInfo(typeof(FileLoadException), new FormatterConverter()); + src.GetObjectData(orig, ctx); + var info = new SerializationInfo(typeof(FileLoadException), new FormatterConverter()); + foreach (var entry in orig) { + if (entry.Name == "FileLoad_FusionLog") continue; + info.AddValue(entry.Name, entry.Value, entry.ObjectType); + } + info.AddValue("FileLoad_FusionLog", fusionLog, typeof(string)); + var ctor = typeof(FileLoadException).GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, null, + new[] { typeof(SerializationInfo), typeof(StreamingContext) }, null); + var ex = (FileLoadException)ctor.Invoke(new object[] { info, ctx }); + Assume.That(ex.FusionLog, Is.EqualTo(fusionLog), "テスト用の FusionLog を設定できること(前提)"); + return ex; + } + } +} +#endif diff --git a/TIASshot.Tests/SelfTestRunnerTests.cs b/TIASshot.Tests/SelfTestRunnerTests.cs new file mode 100644 index 0000000..6962d60 --- /dev/null +++ b/TIASshot.Tests/SelfTestRunnerTests.cs @@ -0,0 +1,95 @@ +#if SELFTEST +using NUnit.Framework; + +namespace TIASshot.Tests { + /// + /// SelfTestRunner のうちテストプロジェクトから参照できる純粋ロジック(引数判定・終了コードの規約)のテスト. + /// + /// 仕様: ENV_04「セルフテスト」・ENV_06「SELFTEST」・.claude/verify-profile.md + /// - `TIASshot.exe --selftest` でセルフテストを実行する(それ以外は通常起動) + /// - 終了コード 0=全項目合格,1=不合格あり,2=セルフテスト自体の異常,3=タイムアウト + /// + /// 対象外: Run 本体(ヘッドレス Form1・偽カメラ・メッセージポンプ・Environment.Exit を伴う)と, + /// private の引数値取得(--selftest-out)・結果集約・リダイレクト突き合わせはハードウェア結合層の一部として + /// ランナー(scripts/verify/run_selftest.py)による実行で担保する. + /// 本クラスは SELFTEST 構成(Debug|x64)でのみコンパイルされる. + /// + [TestFixture] + public class SelfTestRunnerTests { + + [Test] + public void IsRequested_selftestフラグあり_真() { + Assert.That(SelfTestRunner.IsRequested(new[] { @"C:\app\TIASshot.exe", "--selftest" }), Is.True); + } + + [Test] + public void IsRequested_selftestフラグが出力先指定と併用_真() { + var args = new[] { "TIASshot.exe", "--selftest", "--selftest-out", @"C:\tmp\r.txt" }; + Assert.That(SelfTestRunner.IsRequested(args), Is.True); + } + + [Test] + public void IsRequested_大文字小文字違い_真() { + Assert.That(SelfTestRunner.IsRequested(new[] { "TIASshot.exe", "--SelfTest" }), Is.True); + } + + [Test] + public void IsRequested_引数なし_偽で通常起動() { + Assert.That(SelfTestRunner.IsRequested(new[] { "TIASshot.exe" }), Is.False); + } + + [Test] + public void IsRequested_空配列_偽() { + Assert.That(SelfTestRunner.IsRequested(new string[0]), Is.False); + } + + [Test] + public void IsRequested_出力先指定のみでselftestなし_偽() { + // --selftest-out は --selftest の前方一致だが,それ単独ではセルフテストを起動しない + var args = new[] { "TIASshot.exe", "--selftest-out", @"C:\tmp\r.txt" }; + Assert.That(SelfTestRunner.IsRequested(args), Is.False); + } + + [Test] + public void IsRequested_keepのみ_偽() { + Assert.That(SelfTestRunner.IsRequested(new[] { "TIASshot.exe", "--selftest-keep" }), Is.False); + } + + [Test] + public void IsRequested_前後に文字が付いた値_偽() { + Assert.That(SelfTestRunner.IsRequested(new[] { "TIASshot.exe", "-selftest", "selftest", "--selftestx" }), Is.False); + } + + [Test] + public void IsRequested_出力先パスの値がselftestという文字列_真() { + // 出力先の値として "--selftest" が来た場合も含め,フラグの存在で判定する(誤起動にはならない方向の確認) + Assert.That(SelfTestRunner.IsRequested(new[] { "TIASshot.exe", "--selftest-out", "--selftest" }), Is.True); + } + + [Test] + public void 終了コード_合格は0() { + Assert.That(SelfTestRunner.ExitPass, Is.EqualTo(0)); + } + + [Test] + public void 終了コード_不合格ありは1() { + Assert.That(SelfTestRunner.ExitFail, Is.EqualTo(1)); + } + + [Test] + public void 終了コード_セルフテスト自体の異常は2() { + Assert.That(SelfTestRunner.ExitError, Is.EqualTo(2)); + } + + [Test] + public void 終了コード_タイムアウトは3() { + Assert.That(SelfTestRunner.ExitTimeout, Is.EqualTo(3)); + } + + [Test] + public void フラグ名_仕様どおりselftest() { + Assert.That(SelfTestRunner.FlagSelfTest, Is.EqualTo("--selftest")); + } + } +} +#endif diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj index 1ec99e6..3e74bf7 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -22,7 +22,8 @@ true bin\x64\Debug\ - DEBUG;TRACE + + DEBUG;TRACE;SELFTEST full x64 7.3 @@ -96,6 +97,9 @@ + + + diff --git a/TIASshot/Program.cs b/TIASshot/Program.cs index 874ccf0..f875009 100644 --- a/TIASshot/Program.cs +++ b/TIASshot/Program.cs @@ -13,6 +13,15 @@ static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); +#if SELFTEST + // ヘッドレスのセルフテスト(--selftest).フォームを表示せずに検査して終了コードで結果を返す + var args = Environment.GetCommandLineArgs(); + if (SelfTestRunner.IsRequested(args)) { + // 保存スレッド等のフォアグラウンドスレッドが残っても確実に終了コードを返すため Exit で抜ける + Environment.Exit(SelfTestRunner.Run(args)); + return; + } +#endif Application.Run(new Form1()); } } diff --git a/TIASshot/SelfTest/SelfTestCamera.cs b/TIASshot/SelfTest/SelfTestCamera.cs new file mode 100644 index 0000000..93d9ba9 --- /dev/null +++ b/TIASshot/SelfTest/SelfTestCamera.cs @@ -0,0 +1,238 @@ +#if SELFTEST +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Threading; +using OpenCvSharp; +using OpenCvSharp.Extensions; + +namespace TIASshot { + + /// + /// セルフテスト用の偽カメラ(SELFTEST 限定). + /// 実カメラ SDK(Lumenera / IC Imaging Control)には一切触れず,合成フレームを + /// 専用スレッド(SDK のコールバックスレッドの代替)から流す. + /// CameraBase の実コード(BootCheck・ProcessCalibrationFrame・DetectChart・CalcTcc・ + /// ShotOne → RunShotLoop → SaveThread → SaveImages)をそのまま通すことを目的とし, + /// 本クラスで代替するのはカメラ固有部分(フレーム生成・ゲイン反映・撮影開始/終了)のみ. + /// + internal sealed class SelfTestCamera : CameraBase { + + // 合成フレームの生成元(呼び出しごとに新しい Mat を返す.所有権は本クラスへ移る) + private readonly Func _frameFactory; + + // 疑似ゲイン(ApplyWhiteBalance で更新し,生成フレームに乗算する) + private readonly object _gainLock = new object(); + private double _gainB = 1.0; + private double _gainG = 1.0; + private double _gainR = 1.0; + + private Thread _previewThread; + private volatile bool _stopPreview; + private int _frameIndex; + private int _framesDelivered; + + // 本クラスが生成した表示用入力 Bitmap(同一性の比較専用.解放は PreviewCallback の二重バッファが行う) + private readonly List _producedBitmaps = new List(); + private readonly object _producedLock = new object(); + + public SelfTestCamera(Form1 form, Func frameFactory) : base(form) { + _frameFactory = frameFactory; + } + + /// プレビュースレッドで発生した例外(無ければ null) + public Exception PreviewError { get; private set; } + + /// コールバックへ渡したフレーム数 + public int FramesDelivered => Volatile.Read(ref _framesDelivered); + + /// 自動校正が完了したか(CameraBase.CalcTcc が立てるフラグ) + public bool IsCalibrated => _calibrated; + + /// CalcTcc が校正データを保存したフォルダ(RecordCalibrationInfo 時点で記録) + public string CalibrationFolder { get; private set; } + + /// 直近の撮影データの保存フォルダ + public string CurrentSaveFolder => _saveFolder; + + /// プレビュースレッドが動作中か + public bool IsPreviewRunning => _previewThread != null && _previewThread.IsAlive; + + public override bool Connect() { + if (!BootCheck()) return false; + DeviceName = "SelfTestCamera"; + SerialNumber = "SELFTEST"; + SetInfo("カメラ型番", DeviceName); + SetInfo("カメラSN", SerialNumber); + return true; + } + + /// + /// 切断.プレビュースレッドに停止を要求し,二重バッファの Bitmap を解放する. + /// 【前提】プレビュースレッドは UI スレッドへの Invoke で待つことがあるため, + /// 呼び出し元は UI スレッドのメッセージを回しながら IsPreviewRunning が false になるのを待ってから呼ぶこと. + /// + public override void Disconnect() { + _stopPreview = true; + for (var i = 0; i < _bmps.Length; i++) { + _bmps[i]?.Dispose(); + _bmps[i] = null; + } + foreach (var mask in _chartMasks) mask.Dispose(); + _chartMasks.Clear(); + _isPreview = false; + } + + /// + /// プレビュー開始.SDK のコールバックスレッドの代わりに専用スレッドから合成フレームを流す. + /// + /// 流すフレーム数の上限 + /// フレーム間隔(ms) + /// 校正完了で停止するか + public void StartPreview(int maxFrames, int intervalMs, bool stopWhenCalibrated) { + _stopPreview = false; + _isPreview = true; + _previewThread = new Thread(() => PreviewLoop(maxFrames, intervalMs, stopWhenCalibrated)) { + IsBackground = true, + Name = "SelfTestCamera.Preview", + }; + _previewThread.Start(); + } + + /// プレビュー停止を要求する(待機は呼び出し元が UI メッセージを回しながら行う) + public void RequestStopPreview() { + _stopPreview = true; + } + + private void PreviewLoop(int maxFrames, int intervalMs, bool stopWhenCalibrated) { + try { + for (var i = 0; i < maxFrames && !_stopPreview; i++) { + using (var imgt = CreateFrame()) { + PreviewCallback(imgt); + } + Interlocked.Increment(ref _framesDelivered); + if (stopWhenCalibrated && _calibrated) break; + if (intervalMs > 0) Thread.Sleep(intervalMs); + } + } catch (Exception ex) { + // バックグラウンドスレッドの未処理例外はプロセスを落とすため,記録して判定側へ渡す + Debug.WriteLine($"SelfTestCamera プレビュー例外: {ex}"); + PreviewError = ex; + } + } + + /// + /// プレビューコールバック.Lucam.PreviewCallback の imgt 生成以降と同じ手順 + /// (二重バッファへ ToBitmap → 校正フレーム処理 → Form1.ShowImage → 反対側バッファを Dispose). + /// ShowImage に渡した Bitmap の所有権は本クラスに残り,Form1 は読むだけ(FastClone でコピーする). + /// + private void PreviewCallback(Mat imgt) { + var bmp = imgt.ToBitmap(); + _bmps[_bmpIndex] = bmp; + lock (_producedLock) { + _producedBitmaps.Add(bmp); + } + ProcessCalibrationFrame(imgt); + _form.ShowImage(_bmps[_bmpIndex]); + _bmpIndex = (_bmpIndex + 1) % 2; + if (_bmps[_bmpIndex] != null) _bmps[_bmpIndex].Dispose(); + } + + /// 指定の Image が本クラスの生成した入力 Bitmap そのもの(同一インスタンス)か + public bool IsProducedBitmap(Image img) { + if (img == null) return false; + lock (_producedLock) { + foreach (var b in _producedBitmaps) { + if (ReferenceEquals(b, img)) return true; + } + } + return false; + } + + /// 現在の疑似ゲインを乗算した合成フレームを生成する(戻り値の所有権は呼び出し側) + public Mat CreateFrame() { + double gb, gg, gr; + lock (_gainLock) { + gb = _gainB; + gg = _gainG; + gr = _gainR; + } + var idx = Interlocked.Increment(ref _frameIndex) - 1; + using (var baseFrame = _frameFactory(idx)) { + var frame = new Mat(); + try { + Cv2.Multiply(baseFrame, new Scalar(gb, gg, gr), frame); + return frame; + } catch { + frame.Dispose(); + throw; + } + } + } + + /// 現在のチャートマスクで白色票(13 番)の平均値を計測する(校正完了後に使う) + public Scalar MeasureWhitePatch() { + using (var frame = CreateFrame()) { + return Cv2.Mean(frame, _chartMasks[12]); + } + } + + /// 現在の疑似ゲイン(B, G, R) + public string DescribeGain() { + lock (_gainLock) { + return $"B={_gainB:0.000} G={_gainG:0.000} R={_gainR:0.000}"; + } + } + + // ---- CameraBase のテンプレートメソッド(カメラ固有部分の代替) ---------------------- + + protected override void ApplyWhiteBalance(Scalar whitePatch) { + // Lucam と同じくゲインに更新比率(CameraBase.GetRatio=実コード)を掛ける + lock (_gainLock) { + _gainB *= GetRatio((float)whitePatch.Val0, Config.GetFloat("Calib/Reference/B")); + _gainG *= GetRatio((float)whitePatch.Val1, Config.GetFloat("Calib/Reference/G")); + _gainR *= GetRatio((float)whitePatch.Val2, Config.GetFloat("Calib/Reference/R")); + } + } + + protected override void LogCalibrationConverged() { + Debug.WriteLine($"SelfTestCamera ゲイン収束: {DescribeGain()}"); + } + + protected override void RecordCalibrationInfo(Mat imgt) { + CalibrationFolder = _saveFolder; + SetInfo("SelfTestGain", DescribeGain()); + } + + /// + /// 撮影セッション本体.CameraBase.Shot の XML ドキュメントに記載のライフサイクル順序どおりに呼ぶ. + /// + protected override void Shot(int numImages = 1, int interval = 0) { + WaitPreviousSaveThread(); + PrepareShotSession(); + ResetShotQueue(); + StartSaveThread(numImages); + try { + WaitSaveThreadReady(); + BeginShot(); + RunShotLoop(numImages, interval); + } finally { + EndShotSession(); + } + } + + protected override void BeginShot() { + Debug.WriteLine("SelfTestCamera 撮影開始"); + } + + protected override Mat CaptureFrame() { + return CreateFrame(); + } + + protected override void EndShot() { + Debug.WriteLine("SelfTestCamera 撮影終了"); + } + } +} +#endif diff --git a/TIASshot/SelfTest/SelfTestChart.cs b/TIASshot/SelfTest/SelfTestChart.cs new file mode 100644 index 0000000..e450f6a --- /dev/null +++ b/TIASshot/SelfTest/SelfTestChart.cs @@ -0,0 +1,111 @@ +#if SELFTEST +using System; +using OpenCvSharp; +using OpenCvSharp.Aruco; + +namespace TIASshot { + + /// + /// セルフテスト用の合成舌診チャート(SELFTEST 限定).実画像を使わず,ChartDetector の + /// チャート座標系(810x1545,ArUco Dict4X4_50 の ID 40(上)/ 41(下),24 色票)どおりに描く. + /// 色票の色は TCC の sRGB 参照値(tcc_srgb.csv)をチャネルごとの 1 次式で「カメラ色」に歪めたもの. + /// 歪みが 1 次式なので,校正で求まる変換行列を掛ければ参照値へほぼ戻る(色変換の検算に使える). + /// + internal static class SelfTestChart { + + // ChartDetector のチャート座標系 + public const int ChartWidth = 810; + public const int ChartHeight = 1545; + private const int MarkerSize = 120; // PointsDst40/41 の一辺(345→465) + private static readonly Point Marker40 = new Point(345, 130); + private static readonly Point Marker41 = new Point(345, 1200); + private const int PatchRoiSize = 80; // ChartDetector のマスク一辺(マーカー 2 個検出時) + private const int PatchMargin = 15; // ROI の外側に描く余白(検出誤差の吸収) + + // フレーム内でのチャートの配置(カメラ画像の中にチャートが写っている状況を模す) + public const int FrameWidth = 960; + public const int FrameHeight = 1700; + public const int OffsetX = 75; + public const int OffsetY = 78; + + // 「カメラ色」への歪み(チャネルごとの 1 次式 v' = a*v + b.B, G, R の順) + private static readonly double[] DistortA = { 0.72, 0.80, 0.76 }; + private static readonly double[] DistortB = { 12.0, 8.0, 10.0 }; + + /// + /// i 番目の色票 ROI(ChartDetector と同じ式)のフレーム座標を返す. + /// + public static Rect PatchRoi(int i) { + var row = i % 6; + var col = i / 6; + var x = 581 - col * 144; + var y = 318 + row * 144; + return new Rect(OffsetX + x, OffsetY + y, PatchRoiSize, PatchRoiSize); + } + + /// + /// 合成チャートを写したフレーム(CV_8UC3・BGR)を生成する.戻り値の所有権は呼び出し側. + /// + /// TCC の sRGB 参照値(24x3・B,G,R 列) + public static Mat CreateChartFrame(Mat tccSrgb) { + var frame = new Mat(FrameHeight, FrameWidth, MatType.CV_8UC3, new Scalar(60, 60, 60)); + try { + // チャート台紙 + Cv2.Rectangle(frame, new Rect(OffsetX, OffsetY, ChartWidth, ChartHeight), + new Scalar(200, 200, 200), Cv2.FILLED); + + // ArUco マーカー(ID 40 が上,ID 41 が下) + DrawMarker(frame, 40, Marker40); + DrawMarker(frame, 41, Marker41); + + // 24 色票(ROI より一回り大きく塗る) + for (var i = 0; i < 24; i++) { + var roi = PatchRoi(i); + var patch = new Rect(roi.X - PatchMargin, roi.Y - PatchMargin, + PatchRoiSize + PatchMargin * 2, PatchRoiSize + PatchMargin * 2); + Cv2.Rectangle(frame, patch, CameraColor(tccSrgb, i), Cv2.FILLED); + } + return frame; + } catch { + frame.Dispose(); + throw; + } + } + + /// i 番目の色票の「カメラ色」(参照値を 1 次式で歪めた BGR) + public static Scalar CameraColor(Mat tccSrgb, int i) { + var v = new double[3]; + for (var c = 0; c < 3; c++) { + var ref_ = tccSrgb.At(i, c); + v[c] = Math.Max(0, Math.Min(255, DistortA[c] * ref_ + DistortB[c])); + } + return new Scalar(v[0], v[1], v[2]); + } + + private static void DrawMarker(Mat frame, int id, Point chartPos) { + using (var dict = CvAruco.GetPredefinedDictionary(PredefinedDictionaryName.Dict4X4_50)) + using (var marker = new Mat()) + using (var markerBgr = new Mat()) { + dict.GenerateImageMarker(id, MarkerSize, marker, 1); + Cv2.CvtColor(marker, markerBgr, ColorConversionCodes.GRAY2BGR); + using (var dst = new Mat(frame, new Rect(OffsetX + chartPos.X, OffsetY + chartPos.Y, MarkerSize, MarkerSize))) { + markerBgr.CopyTo(dst); + } + } + } + + /// + /// プレビュー確認用の単色フレーム(CV_8UC3・BGR)を生成する.フレーム番号で色を変え, + /// 表示されたのがどのフレームのコピーかを画素値で判別できるようにする. + /// + public static Mat CreatePlainFrame(int index, int width, int height) { + return new Mat(height, width, MatType.CV_8UC3, PlainFrameColor(index)); + } + + /// CreatePlainFrame の index 番目の色(B, G, R) + public static Scalar PlainFrameColor(int index) { + return new Scalar(20 + (index * 10) % 200, 100, 220 - (index * 10) % 200); + } + } +} +#endif diff --git a/TIASshot/SelfTest/SelfTestReport.cs b/TIASshot/SelfTest/SelfTestReport.cs new file mode 100644 index 0000000..1c325bf --- /dev/null +++ b/TIASshot/SelfTest/SelfTestReport.cs @@ -0,0 +1,185 @@ +#if SELFTEST +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +namespace TIASshot { + + /// + /// セルフテスト項目の判定(SELFTEST 限定). + /// + internal enum SelfTestStatus { + Pass, + Fail, + } + + /// + /// セルフテスト 1 項目分の記録(SELFTEST 限定). + /// Fail を 1 つでも記録した項目は FAIL となる.Warn は判定に影響しない参考情報. + /// + internal sealed class SelfTestCheck { + public string Id { get; } + public string Title { get; } + public List Lines { get; } = new List(); + public int FailCount { get; private set; } + public int WarnCount { get; private set; } + + public SelfTestStatus Status => FailCount == 0 ? SelfTestStatus.Pass : SelfTestStatus.Fail; + + public SelfTestCheck(string id, string title) { + Id = id; + Title = title; + } + + public void Info(string msg) => Lines.Add($" - {msg}"); + + public void Warn(string msg) { + WarnCount++; + Lines.Add($" WARN: {msg}"); + } + + public void Fail(string msg) { + FailCount++; + Lines.Add($" FAIL: {msg}"); + } + + /// 条件が偽なら Fail を記録する.真なら ok メッセージを Info として残す(任意). + public bool Expect(bool condition, string failMsg, string okMsg = null) { + if (!condition) { + Fail(failMsg); + return false; + } + if (okMsg != null) Info(okMsg); + return true; + } + + /// 想定外の例外を詳細(例外型・メッセージ・アセンブリ名・Fusion ログ)付きで Fail として記録する. + public void FailException(string context, Exception ex) { + Fail($"{context}: {SelfTestReport.DescribeException(ex)}"); + } + } + + /// + /// セルフテスト結果の出力先(SELFTEST 限定). + /// WinForms アプリ(WinExe)は標準出力がコンソールに繋がらないため, + /// (1) 標準出力が既にリダイレクトされていればそこへ,無ければ親プロセスのコンソールへ AttachConsole して書き, + /// (2) 同じ内容を結果ファイル(UTF-8 BOM 付き)にも逐次書き出す(途中でハングしても書けた分は残る). + /// verifier 等の自動判定は結果ファイルと終了コードを正とする. + /// + internal sealed class SelfTestReport : IDisposable { + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(int dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int nStdHandle); + + private const int ATTACH_PARENT_PROCESS = -1; + private const int STD_OUTPUT_HANDLE = -11; + + private readonly object _lock = new object(); + private readonly StreamWriter _file; + private readonly bool _consoleAvailable; + + public string OutputPath { get; } + + public SelfTestReport(string outputPath) { + OutputPath = outputPath; + _consoleAvailable = PrepareConsole(); + + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + _file = new StreamWriter(outputPath, false, new UTF8Encoding(true)) { AutoFlush = true }; + } + + /// + /// 標準出力の準備.リダイレクト済み(パイプ・ファイル)ならそのまま使い, + /// 未接続なら親プロセスのコンソールにアタッチする.どちらも無ければファイル出力のみとする. + /// + private static bool PrepareConsole() { + var handle = GetStdHandle(STD_OUTPUT_HANDLE); + if (handle != IntPtr.Zero && handle != new IntPtr(-1)) return true; + return AttachConsole(ATTACH_PARENT_PROCESS); + } + + public void WriteLine(string line = "") { + lock (_lock) { + _file.WriteLine(line); + if (_consoleAvailable) { + try { + Console.Out.WriteLine(line); + Console.Out.Flush(); + } catch (IOException ex) { + // コンソール側の失敗(パイプ切断等)は結果ファイルに残す(判定は結果ファイルで行える) + _file.WriteLine($" (標準出力への書き込みに失敗: {ex.Message})"); + } + } + } + } + + public void WriteCheck(SelfTestCheck check) { + WriteLine($"[{(check.Status == SelfTestStatus.Pass ? "PASS" : "FAIL")}] {check.Id}: {check.Title}"); + foreach (var line in check.Lines) WriteLine(line); + } + + public void Dispose() { + lock (_lock) { + _file.Dispose(); + } + } + + /// + /// 例外を 1 行に要約する.内部例外を辿り,アセンブリ解決系(FileNotFound / FileLoad / BadImageFormat)は + /// 対象アセンブリ名と Fusion ログの要点を,型ロード系は LoaderExceptions を含める. + /// + public static string DescribeException(Exception ex) { + var sb = new StringBuilder(); + var depth = 0; + for (var e = ex; e != null && depth < 6; e = e.InnerException, depth++) { + if (depth > 0) sb.Append(" <- inner: "); + sb.Append($"{e.GetType().FullName}: {e.Message}"); + switch (e) { + case FileNotFoundException fnf: + AppendAssemblyDetail(sb, fnf.FileName, fnf.FusionLog); + break; + case FileLoadException fl: + AppendAssemblyDetail(sb, fl.FileName, fl.FusionLog); + break; + case BadImageFormatException bif: + AppendAssemblyDetail(sb, bif.FileName, bif.FusionLog); + break; + case System.Reflection.ReflectionTypeLoadException rtl: + foreach (var le in rtl.LoaderExceptions.Where(x => x != null).Take(5)) { + sb.Append($" [LoaderException: {le.GetType().Name}: {le.Message}]"); + } + break; + } + } + var frames = (ex.StackTrace ?? "") + .Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries) + .Take(4) + .Select(x => x.Trim()); + var stack = string.Join(" | ", frames); + if (stack.Length > 0) sb.Append($" [stack: {stack}]"); + return sb.ToString(); + } + + private static void AppendAssemblyDetail(StringBuilder sb, string fileName, string fusionLog) { + if (!string.IsNullOrEmpty(fileName)) sb.Append($" [assembly: {fileName}]"); + if (string.IsNullOrEmpty(fusionLog)) return; + // Fusion ログは長いため,参照・リダイレクト・失敗理由に関する行だけを抜粋する + var keyLines = fusionLog + .Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries) + .Where(l => l.Contains("LOG: Post-policy reference") || l.Contains("LOG: Redirect") + || l.Contains("WRN:") || l.Contains("ERR:") || l.Contains("Calling assembly")) + .Take(6) + .Select(l => l.Trim()); + var joined = string.Join(" / ", keyLines); + if (joined.Length > 0) sb.Append($" [fusion: {joined}]"); + } + } +} +#endif diff --git a/TIASshot/SelfTest/SelfTestRunner.cs b/TIASshot/SelfTest/SelfTestRunner.cs new file mode 100644 index 0000000..22dc475 --- /dev/null +++ b/TIASshot/SelfTest/SelfTestRunner.cs @@ -0,0 +1,883 @@ +#if SELFTEST +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Windows.Forms; +using System.Xml; +using OpenCvSharp; +using OpenCvSharp.Aruco; +using OpenCvSharp.Extensions; + +namespace TIASshot { + + /// + /// ヘッドレスのセルフテスト(SELFTEST 限定).TIASshot.exe --selftest で起動すると, + /// フォームを表示せずに次の 3 項目を実行し,終了コードと結果ファイル(+標準出力)で結果を返す. + /// + /// deps: 依存アセンブリのロード(バインディングリダイレクト含む)と OpenCvSharp の最小実行 + /// preview: 偽カメラ → Form1.ShowImage(FastClone)→ 本モニタ/副モニタの表示用 Bitmap の経路 + /// calib-save: 合成チャートでチャート検出 → 色変換行列算出 → 撮影 → sRGB 変換画像の保存 + /// + /// 実カメラ SDK には触れない.保存先は一時フォルダに差し替え,ユーザーの設定・データは書き換えない. + /// + /// 引数: + /// --selftest セルフテストを実行する + /// --selftest-out <path> 結果ファイルの出力先(省略時は %TEMP%\TIASshot-selftest\result-<日時>.txt) + /// --selftest-keep 合格時も作業フォルダ(保存画像・CSV)を削除しない + /// 終了コード: 0=全項目合格,1=不合格あり,2=セルフテスト自体の異常,3=タイムアウト + /// + /// 【注意】本クラスのメソッド本体・フィールドでは OpenCvSharp 等の依存型を直接使わず, + /// 各検査はラムダ(別メソッドとして遅延 JIT される)の中で使う.依存 DLL が欠けていても + /// JIT 失敗が検査単位の例外として捕捉され,どの依存が欠けたかを報告できるようにするため. + /// + internal static class SelfTestRunner { + + public const string FlagSelfTest = "--selftest"; + private const string FlagOut = "--selftest-out"; + private const string FlagKeep = "--selftest-keep"; + + public const int ExitPass = 0; + public const int ExitFail = 1; + public const int ExitError = 2; + public const int ExitTimeout = 3; + + private static readonly TimeSpan OverallTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan PreviewTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan CalibrationTimeout = TimeSpan.FromSeconds(120); + private static readonly TimeSpan SaveTimeout = TimeSpan.FromSeconds(60); + + // カメラ SDK 由来のアセンブリ(SDK 未導入環境ではロードできなくても判定に含めない) + private static readonly string[] CameraSdkAssemblyPrefixes = { "TIS.", "lumenera." }; + + // 必ずロードできなければならない依存(NuGet 由来.依存更新で壊れやすいもの) + private static readonly string[] RequiredAssemblies = { + "OpenCvSharp", + "OpenCvSharp.Extensions", + "System.Memory", + "System.Buffers", + "System.Runtime.CompilerServices.Unsafe", + "System.Numerics.Vectors", + "System.Drawing.Common", + }; + + public static bool IsRequested(string[] args) { + return HasFlag(args, FlagSelfTest); + } + + /// + /// セルフテストを実行して終了コードを返す(UI スレッド=STA のメインスレッドから呼ぶこと). + /// + public static int Run(string[] args) { + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + var stamp = $"{DateTime.Now:yyyyMMdd-HHmmss}-{Process.GetCurrentProcess().Id}"; + var tempRoot = Path.Combine(Path.GetTempPath(), "TIASshot-selftest"); + var workDir = Path.Combine(tempRoot, $"work-{stamp}"); + var outPath = Path.GetFullPath(GetArgValue(args, FlagOut) ?? Path.Combine(tempRoot, $"result-{stamp}.txt")); + var keep = HasFlag(args, FlagKeep); + + SelfTestReport report; + try { + report = new SelfTestReport(outPath); + } catch (Exception ex) { + Debug.WriteLine($"セルフテスト結果ファイルを作成できません: {outPath}: {ex}"); + return ExitError; + } + + using (report) + using (var done = new ManualResetEventSlim(false)) { + StartWatchdog(report, done); + AppDomain.CurrentDomain.UnhandledException += (s, e) => { + report.WriteLine($"[FAIL] unhandled: 未処理例外でプロセスが終了します: {SelfTestReport.DescribeException(e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString()))}"); + report.WriteLine("RESULT: FAIL (unhandled exception)"); + Environment.Exit(ExitError); + }; + + var resolveLog = new AssemblyResolveLog(); + resolveLog.Attach(); + + report.WriteLine("TIASshot selftest"); + report.WriteLine($" exe: {Assembly.GetExecutingAssembly().Location}"); + report.WriteLine($" version: {Assembly.GetExecutingAssembly().GetName().Version}"); + report.WriteLine($" build: {BuildDescription()}"); + report.WriteLine($" process: {(Environment.Is64BitProcess ? "x64" : "x86")} / CLR {Environment.Version}"); + report.WriteLine($" started: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + report.WriteLine($" work dir: {workDir}"); + report.WriteLine($" result file: {outPath}"); + report.WriteLine(); + + int exitCode; + try { + // WinForms の UI 例外をダイアログ表示ではなく例外として呼び出し元(DoEvents)へ伝播させる + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException); + + var checks = new List(); + var ctx = new Context { BaseDir = baseDir, WorkDir = workDir }; + + checks.Add(RunCheck(report, resolveLog, "deps", "依存アセンブリのロードと OpenCvSharp の最小実行", + c => CheckDependencies(c, ctx, resolveLog))); + + string setupError = PrepareConfig(ctx); + + checks.Add(RunCheck(report, resolveLog, "preview", "偽カメラ → ShowImage(FastClone)→ 表示用 Bitmap のプレビュー経路", + c => { + if (setupError != null) { c.Fail(setupError); return; } + CheckPreview(c); + })); + + checks.Add(RunCheck(report, resolveLog, "calib-save", "合成チャートでチャート検出 → 色変換行列算出 → 撮影 → sRGB 変換画像の保存", + c => { + if (setupError != null) { c.Fail(setupError); return; } + CheckCalibrationAndSave(c, ctx); + })); + + var failed = checks.Where(x => x.Status == SelfTestStatus.Fail).ToList(); + report.WriteLine(); + if (failed.Count == 0) { + report.WriteLine($"RESULT: PASS ({checks.Count}/{checks.Count})"); + exitCode = ExitPass; + } else { + report.WriteLine($"RESULT: FAIL ({failed.Count}/{checks.Count} failed: {string.Join(", ", failed.Select(x => x.Id))})"); + exitCode = ExitFail; + } + } catch (Exception ex) { + report.WriteLine($"[FAIL] runner: セルフテスト自体が異常終了しました: {SelfTestReport.DescribeException(ex)}"); + report.WriteLine("RESULT: FAIL (runner error)"); + exitCode = ExitError; + } + + CleanupWorkDir(report, workDir, keep || exitCode != ExitPass); + done.Set(); + return exitCode; + } + } + + // ================================================================================ + // 共通 + // ================================================================================ + + private sealed class Context { + public string BaseDir; + public string WorkDir; + public string SaveRoot; // 一時設定の File/SaveFolder + public string UserSaveFolder; // 元の config.xml の File/SaveFolder(書き込まないことの確認用) + } + + private static string BuildDescription() { + var flags = new List(); +#if DEBUG + flags.Add("DEBUG"); +#endif +#if MEMMONITOR + flags.Add("MEMMONITOR"); +#endif +#if WHITEBOARD + flags.Add("WHITEBOARD"); +#endif + flags.Add("SELFTEST"); + return string.Join(" ", flags); + } + + private static bool HasFlag(string[] args, string name) { + return args.Any(a => string.Equals(a, name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// name の直後の引数を値として返す.name が無い・末尾にある・直後が別フラグ("--" 始まり)の場合は null(値の欠落扱い). + /// + private static string GetArgValue(string[] args, string name) { + for (var i = 0; i < args.Length - 1; i++) { + if (!string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) continue; + var value = args[i + 1]; + return value.StartsWith("--", StringComparison.Ordinal) ? null : value; + } + return null; + } + + private static void StartWatchdog(SelfTestReport report, ManualResetEventSlim done) { + var watchdog = new Thread(() => { + if (done.Wait(OverallTimeout)) return; + report.WriteLine($"[FAIL] watchdog: {OverallTimeout.TotalSeconds:0} 秒以内に終了しませんでした(ハングの疑い.Invoke の相互待機・モーダル表示等)"); + report.WriteLine("RESULT: FAIL (timeout)"); + Environment.Exit(ExitTimeout); + }) { + IsBackground = true, + Name = "SelfTest.Watchdog", + }; + watchdog.Start(); + } + + /// + /// 1 項目を実行し,想定外の例外は FAIL として記録する.項目の実行中に解決できなかった + /// アプリ依存アセンブリ(AssemblyResolve)があれば,その項目の FAIL として記録する. + /// + private static SelfTestCheck RunCheck(SelfTestReport report, AssemblyResolveLog resolveLog, + string id, string title, Action body) { + var check = new SelfTestCheck(id, title); + var resolveStart = resolveLog.Count; + var sw = Stopwatch.StartNew(); + try { + body(check); + } catch (Exception ex) { + check.FailException("想定外の例外", ex); + } + var ignorable = 0; + foreach (var entry in resolveLog.Since(resolveStart)) { + if (entry.IsIgnorable) { + ignorable++; // サテライトリソース等の探索(見つからないのが通常) + } else if (IsCameraSdkAssembly(entry.Name)) { + check.Warn($"AssemblyResolve(カメラ SDK・判定対象外): {entry}"); + } else { + check.Fail($"アセンブリを解決できませんでした(AssemblyResolve): {entry}"); + } + } + if (ignorable > 0) check.Info($"リソース系の AssemblyResolve {ignorable} 件(サテライトリソースの探索.判定対象外)"); + check.Info($"所要時間 {sw.ElapsedMilliseconds} ms"); + report.WriteCheck(check); + return check; + } + + /// 検査の 1 ステップを実行し,例外は FAIL として記録して次のステップへ進む + private static bool Step(SelfTestCheck c, string name, Action action) { + try { + action(); + return true; + } catch (Exception ex) { + c.FailException(name, ex); + return false; + } + } + + private static bool IsCameraSdkAssembly(string simpleName) { + if (simpleName == null) return false; + return CameraSdkAssemblyPrefixes.Any(p => simpleName.StartsWith(p, StringComparison.OrdinalIgnoreCase)); + } + + /// 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 を実行ファイル基準の絶対パスに, + /// 効果音を存在しないパス(再生しない)に差し替える.元の config.xml は変更しない. + /// + /// 失敗時はその理由,成功時は 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}"; + 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) { + report.WriteLine($"work dir kept: {workDir}"); + return; + } + try { + Directory.Delete(workDir, true); + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { + report.WriteLine($"WARN: 作業フォルダを削除できませんでした(手動で削除してください): {workDir}: {ex.Message}"); + } + } + + private static bool IsUnder(string path, string root) { + if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(root)) return false; + var p = Path.GetFullPath(path).TrimEnd('\\') + "\\"; + var r = Path.GetFullPath(root).TrimEnd('\\') + "\\"; + return p.StartsWith(r, StringComparison.OrdinalIgnoreCase); + } + + // ================================================================================ + // 項目 1: 依存アセンブリ + // ================================================================================ + + private static void CheckDependencies(SelfTestCheck c, Context ctx, AssemblyResolveLog resolveLog) { + var loaded = new HashSet(StringComparer.OrdinalIgnoreCase); + + Step(c, "実行時設定ファイル(TIASshot.exe.config)", () => { + var cfg = AppDomain.CurrentDomain.SetupInformation.ConfigurationFile; + if (!File.Exists(cfg)) { + c.Fail($"実行時設定ファイルがありません(バインディングリダイレクトが効かない): {cfg}"); + return; + } + CheckBindingRedirects(c, ctx.BaseDir, cfg, loaded); + }); + + Step(c, "参照アセンブリの推移的ロード", () => CheckReferenceClosure(c, ctx.BaseDir, loaded)); + Step(c, "bin 内の管理アセンブリのロード", () => CheckBinAssemblies(c, ctx.BaseDir, loaded)); + + foreach (var name in RequiredAssemblies) { + c.Expect(loaded.Contains(name), $"必須の依存 {name} がロードされていません(bin に無い/参照が外れた可能性)"); + } + + Step(c, "OpenCvSharp の最小実行(ネイティブ DLL・Mat 演算・System.Memory 経由のアクセス)", () => CheckOpenCvRuntime(c)); + Step(c, "BitmapConverter(Mat↔Bitmap)と FastClone", () => CheckBitmapConverter(c)); + Step(c, "同梱の設定・参照ファイル", () => CheckAppFiles(c, ctx.BaseDir)); + Step(c, "Form1 の実コンストラクタ(InitializeComponent)", () => CheckRealForm1Constructor(c)); + + var assemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && IsUnder(a.Location, ctx.BaseDir)) + .Select(a => $"{a.GetName().Name} {a.GetName().Version}") + .OrderBy(x => x); + c.Info($"bin からロード済み: {string.Join(", ", assemblies)}"); + } + + /// + /// バインディングリダイレクトの newVersion と bin の DLL のバージョンが一致し, + /// リダイレクト先の完全名でロードできることを確認する(依存更新でリダイレクトがずれると起動時に FileLoadException). + /// + private static void CheckBindingRedirects(SelfTestCheck c, string baseDir, string configFile, HashSet loaded) { + var doc = new XmlDocument(); + doc.Load(configFile); + var ns = new XmlNamespaceManager(doc.NameTable); + ns.AddNamespace("b", "urn:schemas-microsoft-com:asm.v1"); + var count = 0; + foreach (XmlElement da in doc.SelectNodes("//b:dependentAssembly", ns)) { + var id = da.SelectSingleNode("b:assemblyIdentity", ns) as XmlElement; + var br = da.SelectSingleNode("b:bindingRedirect", ns) as XmlElement; + if (id == null || br == null) continue; + count++; + var name = id.GetAttribute("name"); + var newVersion = br.GetAttribute("newVersion"); + var token = id.GetAttribute("publicKeyToken"); + var culture = string.IsNullOrEmpty(id.GetAttribute("culture")) ? "neutral" : id.GetAttribute("culture"); + var dll = Path.Combine(baseDir, name + ".dll"); + if (!File.Exists(dll)) { + // bin に無い=フレームワーク提供か未使用(例: System.ValueTuple は net471 以降 mscorlib に統合). + // 実際に参照されていれば推移的ロードで検査されるため,ここでは強制ロードしない + // (強制ロードすると不要な AssemblyResolve を自ら発生させる) + c.Info($"redirect {name} → {newVersion}: bin に DLL なし(フレームワーク提供または未使用.参照されていれば推移的ロードで検査)"); + continue; + } + var binVersion = AssemblyName.GetAssemblyName(dll).Version; + if (binVersion != new Version(newVersion)) { + c.Fail($"リダイレクト不一致: {name} は newVersion={newVersion} だが bin の DLL は {binVersion}(App.config の bindingRedirect を更新する必要がある)"); + continue; + } + try { + Assembly.Load(new AssemblyName($"{name}, Version={newVersion}, Culture={culture}, PublicKeyToken={token}")); + loaded.Add(name); + c.Info($"redirect {name} → {newVersion}: bin の DLL と一致・ロード OK"); + } catch (Exception ex) { + c.FailException($"redirect {name} → {newVersion} のロード失敗", ex); + } + } + c.Info($"バインディングリダイレクト {count} 件を確認"); + } + + /// + /// 本体アセンブリの参照を推移的に辿って実際にロードする(bin 由来のアセンブリのみ再帰). + /// 実行時に初めて必要になる依存(例: OpenCvSharp → System.Memory → System.Runtime.CompilerServices.Unsafe)を起動前に解決させる. + /// + private static void CheckReferenceClosure(SelfTestCheck c, string baseDir, HashSet loaded) { + var root = typeof(SelfTestRunner).Assembly; + var queue = new Queue>(); + foreach (var r in root.GetReferencedAssemblies()) queue.Enqueue(new KeyValuePair(r, root.GetName().Name)); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var framework = 0; + while (queue.Count > 0) { + var item = queue.Dequeue(); + var an = item.Key; + if (!seen.Add(an.Name)) continue; + Assembly asm; + try { + asm = Assembly.Load(an); + } catch (Exception ex) { + var msg = $"{an.FullName}(参照元 {item.Value})のロード失敗"; + if (IsCameraSdkAssembly(an.Name)) { + c.Warn($"{msg}(カメラ SDK・判定対象外): {SelfTestReport.DescribeException(ex)}"); + } else { + c.FailException(msg, ex); + } + continue; + } + loaded.Add(an.Name); + if (!IsUnder(asm.Location, baseDir)) { + framework++; + continue; + } + var actual = asm.GetName().Version; + c.Info($"{an.Name} {actual}(参照 {an.Version},参照元 {item.Value}): bin からロード"); + if (IsCameraSdkAssembly(an.Name)) continue; // SDK の内部依存までは辿らない + foreach (var r in asm.GetReferencedAssemblies()) { + queue.Enqueue(new KeyValuePair(r, an.Name)); + } + } + c.Info($"参照を辿ってロードしたアセンブリ {seen.Count} 件(うち GAC/Framework {framework} 件)"); + } + + /// bin 直下の管理アセンブリ(DLL)がすべてロードできることを確認する(ネイティブ DLL は対象外) + private static void CheckBinAssemblies(SelfTestCheck c, string baseDir, HashSet loaded) { + var managed = 0; + foreach (var dll in Directory.GetFiles(baseDir, "*.dll")) { + AssemblyName an; + try { + an = AssemblyName.GetAssemblyName(dll); + } catch (BadImageFormatException) { + continue; // ネイティブ DLL(管理アセンブリでない) + } + managed++; + if (loaded.Contains(an.Name)) continue; + try { + Assembly.Load(an); + loaded.Add(an.Name); + c.Info($"{an.Name} {an.Version}: bin からロード(本体から直接は参照されない)"); + } catch (Exception ex) { + if (IsCameraSdkAssembly(an.Name)) { + c.Warn($"{an.FullName} のロード失敗(カメラ SDK・判定対象外): {SelfTestReport.DescribeException(ex)}"); + } else { + c.FailException($"bin の {Path.GetFileName(dll)}({an.FullName})のロード失敗", ex); + } + } + } + c.Info($"bin 直下の管理アセンブリ {managed} 件を確認"); + } + + private static void CheckOpenCvRuntime(SelfTestCheck c) { + c.Info($"OpenCV {Cv2.GetVersionString()}"); + + using (var src = new Mat(48, 64, MatType.CV_8UC3, new Scalar(10, 20, 30))) + using (var blurred = new Mat()) { + Cv2.GaussianBlur(src, blurred, new OpenCvSharp.Size(5, 5), 0); + var mean = Cv2.Mean(blurred); + c.Expect(Math.Abs(mean.Val0 - 10) < 0.5 && Math.Abs(mean.Val1 - 20) < 0.5 && Math.Abs(mean.Val2 - 30) < 0.5, + $"GaussianBlur/Mean の結果が不正: {mean}"); + + // System.Memory(Span)経由の画素アクセス + var span = src.AsSpan(); + long sum = 0; + for (var i = 0; i < span.Length; i++) sum += span[i]; + c.Expect(sum == 48L * 64 * (10 + 20 + 30), $"Mat.AsSpan の画素合計が不正: {sum}"); + } + + // 行列演算(CV_64F): A * A^-1 ≈ I + using (var a = new Mat(3, 3, MatType.CV_64FC1)) { + var vals = new double[] { 4, 1, 2, 0, 3, 1, 1, 0, 2 }; + for (var i = 0; i < 9; i++) a.At(i / 3, i % 3) = vals[i]; + // MatExpr もネイティブ資源を持つため,ToMat の元の式も using で解放する + using (var invExpr = a.Inv()) + using (var inv = invExpr.ToMat()) + using (var prodExpr = a * inv) + using (var prod = prodExpr.ToMat()) + using (var eyeExpr = Mat.Eye(3, 3, MatType.CV_64FC1)) + using (var eye = eyeExpr.ToMat()) { + var err = Cv2.Norm(prod, eye, NormTypes.L2); + c.Expect(err < 1e-9, $"行列演算(A*A^-1)の誤差が大きい: {err}"); + } + } + + using (var dict = CvAruco.GetPredefinedDictionary(PredefinedDictionaryName.Dict4X4_50)) { + c.Expect(dict.MarkerSize == 4, $"ArUco 辞書のマーカーサイズが不正: {dict.MarkerSize}"); + } + c.Info("Mat 演算・行列演算・Span アクセス・ArUco 辞書: OK"); + } + + private static void CheckBitmapConverter(SelfTestCheck c) { + using (var src = SelfTestChart.CreatePlainFrame(3, 64, 48)) { + Cv2.Circle(src, new OpenCvSharp.Point(20, 20), 8, new Scalar(0, 255, 0), -1); + using (var bmp = src.ToBitmap()) + using (var clone = BitmapUtil.FastClone(bmp)) + using (var back = clone.ToMat()) { + c.Expect(bmp.Width == 64 && bmp.Height == 48, $"ToBitmap のサイズが不正: {bmp.Width}x{bmp.Height}"); + c.Expect(!ReferenceEquals(bmp, clone), "FastClone が同一インスタンスを返した"); + var diff = Cv2.Norm(src, back, NormTypes.L1); + c.Expect(diff == 0, $"Mat → Bitmap → FastClone → Mat の往復で画素が変化: L1={diff}"); + } + } + c.Info("Mat → Bitmap → FastClone → Mat の往復: 画素一致"); + } + + private static void CheckAppFiles(SelfTestCheck c, string baseDir) { + var configPath = Path.Combine(baseDir, "config.xml"); + if (!c.Expect(File.Exists(configPath), $"config.xml がありません: {configPath}")) return; + Config.Load(configPath); + c.Expect(Config.IsLoaded(), "config.xml を読み込めません(ルート要素 Config が無い)"); + foreach (var key in new[] { "File/TccSrgbRef", "File/TccXyzRef" }) { + var path = Path.Combine(baseDir, Config.GetString(key)); + using (var m = IoUtil.LoadMatFromCsv(path)) { + c.Expect(m != null && m.Rows == 24 && m.Cols == 3, + $"{key}({path})を 24x3 として読み込めません"); + } + } + c.Info("config.xml・tcc_srgb.csv・tcc_xyz.csv: OK"); + } + + /// + /// 通常起動と同じ Form1 コンストラクタ(InitializeComponent.IC Imaging Control のコントロール生成を含む)を実行する. + /// Form1_Load(カメラ接続)は表示しないため発火しない.カメラ SDK 起因の失敗は判定対象外(WARN)とする. + /// + private static void CheckRealForm1Constructor(SelfTestCheck c) { + try { + using (var form = new Form1()) { + c.Info($"Form1 コンストラクタ: OK({form.Text})"); + } + } catch (Exception ex) { + if (IsCameraSdkRelated(ex)) { + c.Warn($"Form1 コンストラクタがカメラ SDK 起因で失敗(SDK 未導入環境では想定内): {SelfTestReport.DescribeException(ex)}"); + } else { + c.FailException("Form1 コンストラクタの失敗", ex); + } + } + } + + private static bool IsCameraSdkRelated(Exception ex) { + for (var e = ex; e != null; e = e.InnerException) { + var file = (e as FileNotFoundException)?.FileName ?? (e as FileLoadException)?.FileName; + if (file != null && IsCameraSdkAssembly(new AssemblyName(file).Name)) return true; + if ((e.StackTrace ?? "").Contains("TIS.Imaging")) return true; + } + return false; + } + + // ================================================================================ + // 項目 2: プレビュー経路 + // ================================================================================ + + private static void CheckPreview(SelfTestCheck c) { + const int frames = 12; + const int width = 640; + const int height = 480; + + Form1 form = null; + PreviewMonitor monitor = null; + SelfTestCamera camera = null; + try { + form = new Form1(Form1.SelfTestHeadless.Instance); + monitor = new PreviewMonitor(); // 実コンストラクタ(InitializeComponent を含む) + monitor.SelfTestForceShown(new System.Drawing.Size(1280, 800)); // 副ディスプレイ有りを模す + form.SelfTestStart(monitor); + + camera = new SelfTestCamera(form, i => SelfTestChart.CreatePlainFrame(i, width, height)); + if (!c.Expect(camera.Connect(), $"偽カメラの接続(CameraBase.BootCheck)に失敗: {camera.ErrorMsg}", + "偽カメラ接続(CameraBase コンストラクタ・BootCheck): OK")) return; + + camera.StartPreview(frames, 30, false); + var finished = 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)); + monitor.SelfTestRefresh(); + + // 入力 Bitmap(カメラ側の二重バッファ)をすべて解放してから表示側を検査する. + // 表示側が入力と独立したコピー(FastClone)を持っていれば,解放後も読める. + camera.Disconnect(); + var expected = SelfTestChart.PlainFrameColor(frames - 1); + CheckDisplayedImage(c, "本モニタ picDisplay(MainRefreshTimer_Tick)", form.SelfTestDisplayedImage, camera, width, height, expected); + CheckDisplayedImage(c, "副モニタ PreviewMonitor(UpdateImage → RefreshTimer_Tick)", monitor.SelfTestDisplayedImage, camera, width, height, expected); + + c.Expect(form.SelfTestLastError == null, $"エラー通知(ShowError)が発生: {form.SelfTestLastError}"); + } finally { + ShutdownCamera(camera); + form?.SelfTestShutdown(); + form?.Dispose(); + monitor?.SelfTestShutdown(); + monitor?.Dispose(); + } + } + + private static void CheckDisplayedImage(SelfTestCheck c, string label, Image image, SelfTestCamera camera, + int width, int height, Scalar expectedBgr) { + if (!c.Expect(image != null, $"{label}: 表示用 Bitmap が設定されていません")) return; + c.Expect(!camera.IsProducedBitmap(image), $"{label}: 表示用 Bitmap がカメラの入力 Bitmap そのもの(UI 所有コピーになっていない)"); + var bmp = image as Bitmap; + if (!c.Expect(bmp != null, $"{label}: 表示画像が Bitmap ではありません({image.GetType().Name})")) return; + try { + c.Expect(bmp.Width == width && bmp.Height == height, $"{label}: サイズが不正 {bmp.Width}x{bmp.Height}(期待 {width}x{height})"); + var px = bmp.GetPixel(width / 2, height / 2); + var ok = px.B == (int)expectedBgr.Val0 && px.G == (int)expectedBgr.Val1 && px.R == (int)expectedBgr.Val2; + c.Expect(ok, $"{label}: 画素値が最終フレームと一致しない(R{px.R} G{px.G} B{px.B},期待 R{expectedBgr.Val2} G{expectedBgr.Val1} B{expectedBgr.Val0})", + $"{label}: 最終フレームの UI 所有コピーを表示(入力 Bitmap 解放後も有効)"); + } catch (ArgumentException ex) { + // 解放済み Bitmap へのアクセスは ArgumentException(パラメーターが無効)になる + c.FailException($"{label}: 表示用 Bitmap が入力の解放とともに無効になった(所有権モデル違反の疑い)", ex); + } + } + + private static void ShutdownCamera(SelfTestCamera camera) { + if (camera == null) return; + camera.RequestStopPreview(); + // プレビュースレッドは Invoke で UI スレッドを待つことがあるため,Join ではなくメッセージを回して待つ + PumpUntil(() => !camera.IsPreviewRunning, TimeSpan.FromSeconds(10)); + PumpUntil(() => !camera.IsSaveThreadStarted, TimeSpan.FromSeconds(10)); + camera.Disconnect(); + } + + // ================================================================================ + // 項目 3: 校正〜保存 + // ================================================================================ + + private static void CheckCalibrationAndSave(SelfTestCheck c, Context ctx) { + c.Info($"保存先を一時フォルダに差し替え: {ctx.SaveRoot}(config.xml の {ctx.UserSaveFolder} には書き込まない)"); + + Mat tccSrgb = null; + Mat chart = null; + Form1 form = null; + SelfTestCamera camera = null; + try { + tccSrgb = IoUtil.LoadMatFromCsv(Config.GetString("File/TccSrgbRef")); + if (!c.Expect(tccSrgb != null, "tcc_srgb.csv を読み込めません")) return; + chart = SelfTestChart.CreateChartFrame(tccSrgb); + var chartForFactory = chart; + + form = new Form1(Form1.SelfTestHeadless.Instance); + form.SelfTestStart(null); + camera = new SelfTestCamera(form, i => chartForFactory.Clone()); + if (!c.Expect(camera.Connect(), $"偽カメラの接続に失敗: {camera.ErrorMsg}")) return; + + // --- 自動校正(チャート検出 → 固定判定 → ホワイトバランス → 変換行列算出) --- + var maxFrames = Config.GetInt("Calib/ChartSetCount") + Config.GetInt("Calib/Frames") + 60; + camera.StartPreview(maxFrames, 0, true); + var finished = PumpUntil(() => !camera.IsPreviewRunning, CalibrationTimeout); + if (!c.Expect(finished, $"校正が {CalibrationTimeout.TotalSeconds:0} 秒以内に終わりません({camera.FramesDelivered} フレーム)")) return; + if (camera.PreviewError != null) { + c.FailException("プレビュー(校正)中の例外", camera.PreviewError); + return; + } + if (!c.Expect(camera.IsCalibrated, + $"校正が完了しません({camera.FramesDelivered} フレーム.最後の表示: 「{form.SelfTestMessage}」.チャート検出の失敗が疑われる)")) return; + PumpFor(TimeSpan.FromMilliseconds(100)); // CalcTcc の ShowMessage / EnableShots(Invoke)を処理させる + c.Info($"チャート検出・校正完了: {camera.FramesDelivered} フレーム,疑似ゲイン {camera.DescribeGain()},表示「{form.SelfTestMessage}」"); + c.Expect(form.SelfTestShotsEnabled, "校正完了後に撮影ボタンが有効化されていません(CalcTcc → EnableShots)"); + + var white = camera.MeasureWhitePatch(); + var refB = Config.GetFloat("Calib/Reference/B"); + var refG = Config.GetFloat("Calib/Reference/G"); + var refR = Config.GetFloat("Calib/Reference/R"); + c.Expect(Math.Abs(white.Val0 - refB) < 10 && Math.Abs(white.Val1 - refG) < 10 && Math.Abs(white.Val2 - refR) < 10, + $"白色票がホワイトバランス目標に収束していません: B{white.Val0:0.0} G{white.Val1:0.0} R{white.Val2:0.0}(目標 {refB}/{refG}/{refR})", + $"白色票(13 番)の計測値: B{white.Val0:0.0} G{white.Val1:0.0} R{white.Val2:0.0}(目標 {refB}/{refG}/{refR})"); + + var channels = GetConversionChannels(); + var frameSize = new OpenCvSharp.Size(SelfTestChart.FrameWidth, SelfTestChart.FrameHeight); + CheckCalibrationOutputs(c, ctx, camera.CalibrationFolder, channels, frameSize, tccSrgb); + + // --- 撮影 → 保存(ShotOne → RunShotLoop → SaveThread → SaveImages) --- + var shotOk = Step(c, "撮影(CameraBase.ShotOne)", () => camera.ShotOne()); + var saved = PumpUntil(() => !camera.IsSaveThreadStarted, SaveTimeout); + if (!c.Expect(saved, $"画像保存が {SaveTimeout.TotalSeconds:0} 秒以内に終わりません")) return; + PumpFor(TimeSpan.FromMilliseconds(100)); // SaveThread の ShowMessage / EnableShots(Invoke)を処理させる + if (!shotOk) return; + c.Expect(form.SelfTestLastError == null, $"エラー通知(ShowError)が発生: {form.SelfTestLastError}"); + c.Expect(form.SelfTestShotsEnabled, "保存完了後に撮影ボタンが再有効化されていません(SaveThread → EnableShots(true))"); + c.Info($"撮影・保存完了: 表示「{form.SelfTestMessage}」"); + CheckShotOutputs(c, ctx, camera.CurrentSaveFolder, camera.CalibrationFolder, channels, frameSize, tccSrgb); + } finally { + ShutdownCamera(camera); + form?.SelfTestShutdown(); + form?.Dispose(); + chart?.Dispose(); + tccSrgb?.Dispose(); + } + } + + /// ColorCorrector と同じ規則で変換行列のチャネル数一覧を得る(4〜17 の範囲・重複除去・昇順) + private static int[] GetConversionChannels() { + return Config.GetString("Calib/ConversionChannels") + .Split(',') + .Select(x => int.Parse(x.Trim())) + .Where(x => x >= 4 && x <= 17) + .Distinct() + .OrderBy(x => x) + .ToArray(); + } + + private static string ChannelFile(string key, int channel, int no = 1) { + return Config.GetString(key).Replace("{CN}", $"{channel:00}").Replace("{NO}", $"{no:0000}"); + } + + private static void CheckCalibrationOutputs(SelfTestCheck c, Context ctx, string folder, int[] channels, + OpenCvSharp.Size frameSize, Mat tccSrgb) { + if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"校正データのフォルダがありません: {folder}")) return; + c.Expect(IsUnder(folder, ctx.SaveRoot), $"校正データが一時フォルダの外に保存されました: {folder}"); + + CheckImageFile(c, Path.Combine(folder, Config.GetString("File/TccImage")), frameSize); + CheckImageFile(c, Path.Combine(folder, Config.GetString("File/TccRoisImage")), frameSize); + CheckCsvMatrix(c, Path.Combine(folder, Config.GetString("File/TccRgbValues")), 24, 3); + + foreach (var ch in channels) { + CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2Srgb", ch)), ch, 3); + CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2Xyz", ch)), ch, 3); + CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvSrgb2Xyz", ch)), ch, 3); + if (ch == 17) CheckCsvMatrix(c, Path.Combine(folder, ChannelFile("File/ConvRgb2XyzOld", ch)), ch, 3); + + // 校正チャートを変換した色票値が参照値に戻っているか(合成チャートの歪みは 1 次式なので戻るはず) + var convertedFile = ChannelFile("File/TccSrgbValues", ch); + using (var converted = IoUtil.LoadMatFromCsv(Path.Combine(folder, convertedFile))) { + if (!c.Expect(converted != null && converted.Rows == 24 && converted.Cols == 3, + $"{convertedFile} を 24x3 として読み込めません")) continue; + var maxErr = 0.0; + for (var i = 0; i < 24; i++) { + for (var j = 0; j < 3; j++) { + maxErr = Math.Max(maxErr, Math.Abs(converted.At(i, j) - tccSrgb.At(i, j))); + } + } + c.Expect(maxErr < 5.0, $"{ch} 次元: 色票の変換値が参照値から離れすぎ(最大誤差 {maxErr:0.00})", + $"{ch} 次元: 変換行列(有限値)と色票の変換値(参照値との最大誤差 {maxErr:0.00})"); + } + } + } + + private static void CheckShotOutputs(SelfTestCheck c, Context ctx, string folder, string calibFolder, int[] channels, + OpenCvSharp.Size frameSize, Mat tccSrgb) { + if (!c.Expect(!string.IsNullOrEmpty(folder) && Directory.Exists(folder), $"撮影データのフォルダがありません: {folder}")) return; + c.Expect(IsUnder(folder, ctx.SaveRoot), $"撮影データが一時フォルダの外に保存されました: {folder}"); + c.Expect(!string.Equals(folder, calibFolder, StringComparison.OrdinalIgnoreCase), "撮影データが校正データと同じフォルダに保存されました"); + + var info = Path.Combine(folder, Config.GetString("File/Info")); + if (c.Expect(File.Exists(info), $"撮影情報がありません: {info}")) { + var text = File.ReadAllText(info); + c.Expect(text.Contains("Shot,Time(ms),Note"), $"撮影情報の書式が不正: {info}"); + } + CheckImageFile(c, Path.Combine(folder, ChannelFile("File/RgbImage", 0)), frameSize); + + foreach (var ch in channels) { + var path = Path.Combine(folder, ChannelFile("File/SrgbImage", ch)); + if (!CheckImageFile(c, path, frameSize)) continue; + // 変換画像上の色票が参照値(tcc_srgb)に戻っているか(JPEG 圧縮の誤差を許容) + using (var img = Cv2.ImRead(path, ImreadModes.Color)) { + double sumErr = 0, maxErr = 0; + for (var i = 0; i < 24; i++) { + var roi = SelfTestChart.PatchRoi(i); + var inner = new Rect(roi.X + 10, roi.Y + 10, roi.Width - 20, roi.Height - 20); + using (var patch = new Mat(img, inner)) { + var mean = Cv2.Mean(patch); + for (var j = 0; j < 3; j++) { + var err = Math.Abs(mean[j] - tccSrgb.At(i, j)); + sumErr += err; + maxErr = Math.Max(maxErr, err); + } + } + } + var meanErr = sumErr / 72; + c.Expect(meanErr < 6.0 && maxErr < 15.0, + $"{Path.GetFileName(path)}: 色票が参照値に戻っていない(平均誤差 {meanErr:0.00},最大 {maxErr:0.00})", + $"{Path.GetFileName(path)}: 保存・読み戻し OK,色票の参照値との誤差 平均 {meanErr:0.00} / 最大 {maxErr:0.00}"); + } + } + } + + private static bool CheckImageFile(SelfTestCheck c, string path, OpenCvSharp.Size expectedSize) { + if (!c.Expect(File.Exists(path), $"画像がありません: {path}")) return false; + using (var img = Cv2.ImRead(path, ImreadModes.Color)) { + if (!c.Expect(!img.Empty(), $"画像を読み戻せません: {path}")) return false; + return c.Expect(img.Size() == expectedSize, $"画像サイズが不正: {path}({img.Width}x{img.Height},期待 {expectedSize.Width}x{expectedSize.Height})"); + } + } + + private static void CheckCsvMatrix(SelfTestCheck c, string path, int rows, int cols) { + if (!c.Expect(File.Exists(path), $"CSV がありません: {path}")) return; + using (var m = IoUtil.LoadMatFromCsv(path)) { + if (!c.Expect(m != null, $"CSV を読み戻せません: {path}")) return; + if (!c.Expect(m.Rows == rows && m.Cols == cols, $"CSV の形状が不正: {path}({m.Rows}x{m.Cols},期待 {rows}x{cols})")) return; + var allFinite = true; + var allZero = true; + for (var i = 0; i < m.Rows; i++) { + for (var j = 0; j < m.Cols; j++) { + var v = m.At(i, j); + if (double.IsNaN(v) || double.IsInfinity(v)) allFinite = false; + if (v != 0) allZero = false; + } + } + c.Expect(allFinite, $"CSV に非有限値(NaN/Infinity)が含まれます: {path}"); + c.Expect(!allZero, $"CSV が全て 0 です: {path}"); + } + } + + // ================================================================================ + // AssemblyResolve の記録 + // ================================================================================ + + /// + /// AppDomain.AssemblyResolve(通常のバインドで見つからなかったアセンブリ)を記録する. + /// 解決はせず null を返す(挙動は変えない). + /// + private sealed class AssemblyResolveLog { + internal sealed class Entry { + public string Name; + public string FullName; + public string Requester; + public bool IsIgnorable => Name.EndsWith(".resources", StringComparison.OrdinalIgnoreCase) + || Name.EndsWith(".XmlSerializers", StringComparison.OrdinalIgnoreCase); + public override string ToString() => $"{FullName}(要求元 {Requester ?? "不明"})"; + } + + private readonly List _entries = new List(); + private readonly object _lock = new object(); + + public int Count { + get { lock (_lock) return _entries.Count; } + } + + public void Attach() { + AppDomain.CurrentDomain.AssemblyResolve += (s, e) => { + var name = new AssemblyName(e.Name).Name; + lock (_lock) { + _entries.Add(new Entry { Name = name, FullName = e.Name, Requester = e.RequestingAssembly?.GetName().Name }); + } + return null; + }; + } + + public List Since(int start) { + lock (_lock) return _entries.Skip(start).ToList(); + } + } + } +} +#endif diff --git a/TIASshot/TIASshot.csproj b/TIASshot/TIASshot.csproj index b7543f8..35f36d7 100644 --- a/TIASshot/TIASshot.csproj +++ b/TIASshot/TIASshot.csproj @@ -22,7 +22,7 @@ full false bin\Debug\ - DEBUG;TRACE;WHITEBOARD;MEMMONITOR + DEBUG;TRACE;WHITEBOARD;MEMMONITOR;SELFTEST prompt 4 true @@ -40,7 +40,7 @@ true bin\x64\Debug\ - DEBUG;TRACE;WHITEBOARD;MEMMONITOR + DEBUG;TRACE;WHITEBOARD;MEMMONITOR;SELFTEST full x64 7.3 @@ -142,6 +142,9 @@ Form1.cs + + Form1.cs + Form @@ -159,6 +162,13 @@ PreviewMonitor.cs + + PreviewMonitor.cs + + + + + diff --git a/TIASshot/UI/Form1.SelfTest.cs b/TIASshot/UI/Form1.SelfTest.cs new file mode 100644 index 0000000..4dcd702 --- /dev/null +++ b/TIASshot/UI/Form1.SelfTest.cs @@ -0,0 +1,108 @@ +#if SELFTEST +using System.Diagnostics; +using System.Drawing; +using System.Windows.Forms; + +namespace TIASshot { + public partial class Form1 { + + /// + /// ヘッドレス(非表示)セルフテスト用コンストラクタの目印型(SELFTEST 限定). + /// + internal sealed class SelfTestHeadless { + internal static readonly SelfTestHeadless Instance = new SelfTestHeadless(); + private SelfTestHeadless() { } + } + + // ヘッドレスモードか(ShowError をモーダル表示せず記録に切り替える) + private bool _selfTestHeadless = false; + // ヘッドレスモードで ShowError に渡されたメッセージ(最後の 1 件) + private string _selfTestLastError = null; + + /// + /// ヘッドレス(非表示)セルフテスト用コンストラクタ(SELFTEST 限定). + /// InitializeComponent は IC Imaging Control(カメラ SDK)のコントロールを生成するため呼ばず, + /// プレビュー・校正・撮影の経路(ShowImage / MainRefreshTimer_Tick / ShowMessage / EnableShots / + /// GetDataName 等)が参照するコントロールだけを生成する. + /// 本モニタの再描画タイマーの設定は Form1_Load と同じ値で行う(Form1_Load はカメラ接続を含むため呼ばない). + /// + internal Form1(SelfTestHeadless marker) { + _selfTestHeadless = true; + + picPreview = new PictureBox(); + picDisplay = new PictureBox { Size = new Size(449, 610), SizeMode = PictureBoxSizeMode.Zoom }; + txtMessage = new TextBox(); + txtDataName = new TextBox { Text = "selftest" }; + txtNumMultiShots = new TextBox { Text = "1" }; + txtMultiShotsInterval = new TextBox { Text = "0" }; + txtDeviceName = new TextBox(); + txtSerialNo = new TextBox(); + txtSaveFolder = new TextBox(); + txtConvElemNum = new TextBox(); + btnShotOne = new Button(); + btnShotMulti = new Button(); + btnLightSW = new Button(); + btnOpenDataFolder = new Button(); + Controls.AddRange(new Control[] { + picPreview, picDisplay, txtMessage, txtDataName, txtNumMultiShots, txtMultiShotsInterval, + txtDeviceName, txtSerialNo, txtSaveFolder, txtConvElemNum, + btnShotOne, btnShotMulti, btnLightSW, btnOpenDataFolder, + }); + ShowInTaskbar = false; + Text = $"{APP_NAME} [SELFTEST]"; + + _mainRefreshTimer.Interval = MainRefreshIntervalMs; + _mainRefreshTimer.Tick += MainRefreshTimer_Tick; + } + + /// + /// ウィンドウハンドルを生成し(表示はしない),本モニタの再描画タイマーを開始する. + /// ハンドルが無いと InvokeRequired が常に false となり,ワーカースレッドからの + /// ShowMessage / EnableShots が UI スレッドへマーシャリングされない(実運用と異なる経路になる)ため必ず呼ぶ. + /// + /// 副モニタ(null 可) + internal void SelfTestStart(PreviewMonitor previewMonitor) { + CreateHandle(); + _previewMonitor = previewMonitor; + EnableShots(false); + _mainRefreshTimer.Start(); + } + + /// 本モニタ(picDisplay)に現在表示中の画像 + internal Image SelfTestDisplayedImage => picDisplay.Image; + + /// ステータス表示(txtMessage)の現在値 + internal string SelfTestMessage => txtMessage.Text; + + /// ヘッドレスモードで ShowError に渡された最後のメッセージ(無ければ null) + internal string SelfTestLastError => _selfTestLastError; + + /// 撮影ボタンが有効か + internal bool SelfTestShotsEnabled => btnShotOne.Enabled && btnShotMulti.Enabled; + + /// ShowError のヘッドレス版(モーダルダイアログを出すと無人実行が止まるため記録のみ行う) + private void SelfTestRecordError(string msg) { + Debug.WriteLine($"[SELFTEST] ShowError: {msg}"); + _selfTestLastError = msg; + } + + /// + /// タイマー停止と表示用 Bitmap の解放(FormClosing の代わり.Dispose の前に UI スレッドで呼ぶ). + /// + internal void SelfTestShutdown() { + _mainRefreshTimer.Stop(); + _mainRefreshTimer.Dispose(); + var shown = picDisplay.Image; + picDisplay.Image = null; + shown?.Dispose(); + Bitmap pending; + lock (_dispBufLock) { + pending = _dispBuf; + _dispBuf = null; + _isUpdateBuf = false; + } + pending?.Dispose(); + } + } +} +#endif diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index a742849..d091a55 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -290,6 +290,13 @@ Invoke((MethodInvoker)delegate { ShowError(msg); }); return; } +#if SELFTEST + // ヘッドレスのセルフテストではモーダル表示すると無人実行が止まるため,記録のみ行う + if (_selfTestHeadless) { + SelfTestRecordError(msg); + return; + } +#endif MessageBox.Show(msg, APP_NAME, MessageBoxButtons.OK, MessageBoxIcon.Warning); } diff --git a/TIASshot/UI/PreviewMonitor.SelfTest.cs b/TIASshot/UI/PreviewMonitor.SelfTest.cs new file mode 100644 index 0000000..4c6baec --- /dev/null +++ b/TIASshot/UI/PreviewMonitor.SelfTest.cs @@ -0,0 +1,45 @@ +#if SELFTEST +using System; +using System.Drawing; + +namespace TIASshot { + public partial class PreviewMonitor { + + /// + /// 副ディスプレイが接続されている状態を模す(SELFTEST 限定). + /// IsShown は副ディスプレイ検出時に設定される _size で決まるため,ヘッドレス環境でも + /// Form1.ShowImage の副モニタ給餌経路(FastClone → UpdateImage)を通せるようにする. + /// + /// 模擬する副ディスプレイのサイズ + internal void SelfTestForceShown(Size size) { + _size = size; + } + + /// + /// 再描画タイマーの Tick 処理(RefreshTimer_Tick)を 1 回実行する(UI スレッドから呼ぶこと). + /// ヘッドレスでは Load が発火せずタイマーが開始されないため,Tick を直接駆動する. + /// + internal void SelfTestRefresh() { + RefreshTimer_Tick(this, EventArgs.Empty); + } + + /// 副モニタ(picPreview)に現在表示中の画像 + internal Image SelfTestDisplayedImage => picPreview.Image; + + /// タイマー停止と表示用 Bitmap の解放(Dispose の前に UI スレッドで呼ぶ) + internal void SelfTestShutdown() { + _refreshTimer.Stop(); + var shown = picPreview.Image; + picPreview.Image = null; + shown?.Dispose(); + Bitmap pending; + lock (_dispBufLock) { + pending = _dispBuf; + _dispBuf = null; + _isUpdateBuf = false; + } + pending?.Dispose(); + } + } +} +#endif 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 13673da..877a070 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" @@ -97,3 +97,23 @@ ``` 実機カメラ・光源に接続して動作する UI アプリのため,完全な動作確認には機材が必要. + +## セルフテスト (Self Test) + +実機なしで確認できる範囲(依存アセンブリのロード・プレビュー経路・校正〜保存)を,フォームを表示せずに検査する(`Debug|x64` のみ.[ENV_06](ENV_06_ビルド構成とビルドフラグ.md) の `SELFTEST`). + +```powershell +# クリーンビルド → 既存テスト → セルフテストをまとめて実行する(結果は .verify/selftest/run-<日時>/) +python scripts/verify/run_selftest.py +# 依存パッケージを変更したとき(packages を退避して nuget restore からやり直す) +python scripts/verify/run_selftest.py --full + +# セルフテスト単体(WinExe のため Start-Process -Wait で終了コードを受け取る) +$p = Start-Process "TIASshot\bin\x64\Debug\TIASshot.exe" -ArgumentList "--selftest","--selftest-out","$env:TEMP\tias-selftest.txt" -Wait -PassThru +$p.ExitCode # 0=全項目合格,1=不合格あり,2=セルフテスト自体の異常,3=タイムアウト +Get-Content "$env:TEMP\tias-selftest.txt" # 各項目の PASS/FAIL と理由(UTF-8 BOM 付き) +``` + +- ランナーがクリーンするのは Debug 出力(`TIASshot/` と `TIASshot.Tests/` の `bin/x64/Debug`・`obj/x64/Debug`)のみ.`bin/x64/Release` 等ほかの構成の出力は消さない.`--no-clean`・`--skip-tests` は開発時の短縮用で,検証の判定には使わない. +- `--selftest-keep` を付けると,合格時も作業フォルダ(`%TEMP%\TIASshot-selftest\work-*`.校正データ・保存画像)を残す(不合格時は常に残る). +- verifier(`/verify`)による自動検証の手順・判定の読み方は `.claude/verify-profile.md` を参照する. diff --git "a/docs/02_ENV/ENV_05_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.md" "b/docs/02_ENV/ENV_05_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.md" index 8bdcde3..701c572 100644 --- "a/docs/02_ENV/ENV_05_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.md" +++ "b/docs/02_ENV/ENV_05_\343\203\207\343\202\243\343\203\254\343\202\257\343\203\210\343\203\252\346\247\213\346\210\220.md" @@ -9,6 +9,8 @@ ├── TIASshot.sln ← Visual Studio ソリューションファイル ├── TIASshot/ ← メインプロジェクト ├── TIASshot.Tests/ ← テストプロジェクト(NUnit / x64) +├── scripts/verify/ ← 検証スクリプト(run_selftest.py:クリーンビルド→テスト→セルフテスト./verify から使う) +├── .verify/ ← 検証スクリプトの出力(gitignore 対象) └── docs/ ← ドキュメント ``` @@ -41,7 +43,14 @@ │ ├── MemoryMonitorForm.cs ← メモリ観測ウィンドウ(MEMMONITOR 限定) │ ├── MemoryMonitorForm.Designer.cs ← 同デザイナーコード(MEMMONITOR 限定) │ ├── PreviewProfiler.cs ← プレビュー計測カウンタ(MEMMONITOR 限定) -│ └── TimedPictureBox.cs ← 副モニタ実描画時間の計測(MEMMONITOR 限定) +│ ├── TimedPictureBox.cs ← 副モニタ実描画時間の計測(MEMMONITOR 限定) +│ ├── Form1.SelfTest.cs ← ヘッドレス用コンストラクタ・後始末(SELFTEST 限定の partial) +│ └── PreviewMonitor.SelfTest.cs ← 副モニタの模擬・タイマー手動実行(SELFTEST 限定の partial) +├── SelfTest/ ← ヘッドレスのセルフテスト(SELFTEST 限定.--selftest) +│ ├── SelfTestRunner.cs ← 引数解析・3 項目の実行・終了コード決定 +│ ├── SelfTestCamera.cs ← CameraBase を継承した偽カメラ +│ ├── SelfTestChart.cs ← 合成チャート(ArUco 40/41+24 色票)の生成 +│ └── SelfTestReport.cs ← 結果ファイル出力・例外要約 └── Utils/ ← ユーティリティ ├── IoUtil.cs ← ファイル I/O(CSV・保存フォルダ・撮影情報)・サウンド ├── BitmapUtil.cs ← Bitmap 生画素コピー(FastClone・純粋ロジック) diff --git "a/docs/02_ENV/ENV_06_\343\203\223\343\203\253\343\203\211\346\247\213\346\210\220\343\201\250\343\203\223\343\203\253\343\203\211\343\203\225\343\203\251\343\202\260.md" "b/docs/02_ENV/ENV_06_\343\203\223\343\203\253\343\203\211\346\247\213\346\210\220\343\201\250\343\203\223\343\203\253\343\203\211\343\203\225\343\203\251\343\202\260.md" index f22905f..1460d5b 100644 --- "a/docs/02_ENV/ENV_06_\343\203\223\343\203\253\343\203\211\346\247\213\346\210\220\343\201\250\343\203\223\343\203\253\343\203\211\343\203\225\343\203\251\343\202\260.md" +++ "b/docs/02_ENV/ENV_06_\343\203\223\343\203\253\343\203\211\346\247\213\346\210\220\343\201\250\343\203\223\343\203\253\343\203\211\343\203\225\343\203\251\343\202\260.md" @@ -5,7 +5,7 @@ ## 設計方針 (Policy) - **Release(配布版)=素の製品**.診断・評価・デモ用の機能はコードごと除外する. -- **Debug=開発・検証版**.診断/評価機能(`MEMMONITOR`・`WHITEBOARD`)を自動で含める. +- **Debug=開発・検証版**.診断/評価機能(`MEMMONITOR`・`WHITEBOARD`・`SELFTEST`)を自動で含める. ## ソリューション構成 (Solution Configurations) @@ -13,7 +13,7 @@ | 構成 | 用途 | 定義されるシンボル | 出力先 | | --- | --- | --- | --- | -| `Debug\|x64` | 開発・検証 | `DEBUG` `TRACE` `WHITEBOARD` `MEMMONITOR` | `TIASshot/bin/x64/Debug/` | +| `Debug\|x64` | 開発・検証 | `DEBUG` `TRACE` `WHITEBOARD` `MEMMONITOR` `SELFTEST` | `TIASshot/bin/x64/Debug/` | | `Release\|x64` | 配布 | `TRACE` | `TIASshot/bin/x64/Release/` | > ※ `TIASshot.csproj` には `Debug\|AnyCPU` / `Release\|AnyCPU` の PropertyGroup も残っているが,`.sln` は `x64` のみをビルド対象としており,通常はこれらを使わない. @@ -25,6 +25,7 @@ | --- | --- | --- | --- | --- | | `MEMMONITOR` | メモリ観測ウィンドウ+プレビュー計測 | `Debug\|x64`(自動) | ✗ | `UI/MemoryMonitorForm`,`UI/PreviewProfiler`,`UI/TimedPictureBox`,`UI/Form1.MemMonitor`(F9 トグル),`Form1.ShowImage` 等のプレビュー計測 | | `WHITEBOARD` | 白板撮影モード(照明均一性評価用) | `Debug\|x64`(自動) | ✗ | 白板撮影ボタン(`Form1` の専用 partial),`Cameras/IScam.ShotWhiteBoard` 等 | +| `SELFTEST` | ヘッドレスのセルフテスト(`--selftest`) | `Debug\|x64`(自動)・`Debug\|AnyCPU`(csproj のみ.通常は未使用) | ✗ | `SelfTest/`(ランナー・偽カメラ・合成チャート),`UI/Form1.SelfTest`・`UI/PreviewMonitor.SelfTest`,`Program.Main` の分岐,`Form1.ShowError` のヘッドレス記録 | | `DEBUG` | .NET 標準デバッグシンボル | `Debug\|x64` | ✗ | 標準(一部の開発時分岐) | | `TRACE` | .NET 標準トレースシンボル | 全構成 | ○ | `System.Diagnostics.Trace` | @@ -41,9 +42,15 @@ - ImagingSource(IScam)でのみ実装.未対応カメラでは通知して何もしない. - 配布版の舌診撮影フローには不要なため Release では未定義. +### SELFTEST — ヘッドレスのセルフテスト + +- `TIASshot.exe --selftest` でフォームを表示せずに,依存アセンブリのロード(`deps`)・偽カメラによるプレビュー経路(`preview`)・合成チャートによる校正〜保存(`calib-save`)を検査し,終了コード(0=合格)と結果ファイルで返す.実機カメラ・光源には触れず,保存先は一時フォルダに差し替える. +- 依存更新後などに,人間の実機確認の前段として verifier(`/verify`)が使う.実行手順は [ENV_04_開発コマンド](ENV_04_開発コマンド.md) と `.claude/verify-profile.md` を参照する. +- 配布版には不要なため Release では未定義(Release の exe に `--selftest` を渡しても通常起動になる). + ## ビルド方法 (How to Build) -標準構成(Debug / Release)のビルド・テスト・実行コマンドは [ENV_04_開発コマンド](ENV_04_開発コマンド.md) を参照する.`Debug|x64` では `WHITEBOARD` と `MEMMONITOR` が自動で有効になり(メモリ観測ウィンドウが開き,白板撮影ボタンが表示される),`Release|x64` ではいずれの診断フラグも含まれない. +標準構成(Debug / Release)のビルド・テスト・実行コマンドは [ENV_04_開発コマンド](ENV_04_開発コマンド.md) を参照する.`Debug|x64` では `WHITEBOARD`・`MEMMONITOR`・`SELFTEST` が自動で有効になり(メモリ観測ウィンドウが開き,白板撮影ボタンが表示され,`--selftest` 引数が使える),`Release|x64` ではいずれの診断フラグも含まれない. ## 関連 (Related) diff --git "a/docs/05_TECH/TECH_01_\343\202\257\343\203\251\343\202\271\350\250\255\350\250\210.md" "b/docs/05_TECH/TECH_01_\343\202\257\343\203\251\343\202\271\350\250\255\350\250\210.md" index dce04e8..bc98bef 100644 --- "a/docs/05_TECH/TECH_01_\343\202\257\343\203\251\343\202\271\350\250\255\350\250\210.md" +++ "b/docs/05_TECH/TECH_01_\343\202\257\343\203\251\343\202\271\350\250\255\350\250\210.md" @@ -72,12 +72,20 @@ - 飽和統計(飽和率・p99・チャンネル別 mean/max)の算出と JSON サイドカー生成.ハードウェアに依存しない純粋計算. - 白板撮影モード(`#if WHITEBOARD`)から使用される(テスト可能なピュアロジック層). +**SelfTest(`#if SELFTEST`.Debug のみ)** + +- ファイル: `SelfTest/SelfTestRunner.cs`・`SelfTestCamera.cs`・`SelfTestChart.cs`・`SelfTestReport.cs`,`UI/Form1.SelfTest.cs`・`UI/PreviewMonitor.SelfTest.cs` +- `TIASshot.exe --selftest` のヘッドレス検査([ENV_06](../02_ENV/ENV_06_ビルド構成とビルドフラグ.md)).`SelfTestRunner` が `deps`・`preview`・`calib-save` を順に実行し終了コードを決める. +- `SelfTestCamera` は `CameraBase` を継承した偽カメラで,`Lucam.PreviewCallback` と同じ手順(二重バッファ → `ProcessCalibrationFrame` → `ShowImage`)で合成フレームを流す.`SelfTestChart` は ArUco マーカー(ID 40/41)と 24 色票の合成チャートを生成する. +- `Form1`・`PreviewMonitor` の partial はヘッドレス用コンストラクタと後始末を提供する.`Form1.ShowImage` 等が新しいコントロールを参照するようになった場合は,ヘッドレス用コンストラクタにも追従が必要. + ## 継承関係 (Inheritance) ```text CameraBase(抽象) ├── IScam ← ImagingSource カメラ -└── Lucam ← Lumenera カメラ +├── Lucam ← Lumenera カメラ +└── SelfTestCamera ← セルフテスト用の偽カメラ(#if SELFTEST) ``` ## CameraBase の主なメンバ (CameraBase Members) 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 b7c4650..206f15c 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" @@ -8,8 +8,17 @@ - ビルドテスト: コンパイルエラーがないことを確認する - 回帰テスト: リファクタリング前後の出力が一致することを確認する +- セルフテスト: 実機なしで依存ロード・プレビュー経路・校正〜保存を自動確認する(手動テストの前段) - 手動テスト: 実機を使って動作を目視確認する +## セルフテスト (Self Test) + +依存更新やハードウェア結合層の変更後,手動テストの前に実施する(`Debug|x64` のみ.手順は [ENV_04](../02_ENV/ENV_04_開発コマンド.md) の「セルフテスト」,判定は `.claude/verify-profile.md`.`/verify` から自動実行できる). + +- `python scripts/verify/run_selftest.py`(依存を変えた場合は `--full`) +- **合格条件**: 終了コード 0(`deps`・`preview`・`calib-save` の 3 項目が PASS) +- 代替していない範囲(実カメラ SDK との結合・実プレビュー表示・実チャートの検出性能・画像の見た目・長時間のメモリ/GDI)は,引き続き下記の手動テストで確認する + ## ビルドテスト (Build Test) 全フェーズで実施する. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7ceed8b..2c8fa80 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -152,3 +152,9 @@ - 失敗パターン: 代わりに NUnit 4.6 固有の CS0121 が出た.`Assert.Throws`/`DoesNotThrow` が `TestDelegate` 版(Obsolete)と `Action` 版(`[OverloadResolutionPriority]`)を併せ持ち,LangVersion 7.3 では優先度属性が効かずラムダの解決が曖昧になる.LangVersion を上げると古い VS / Build Tools でビルドできなくなるため,`(Action)` キャストで解消し,書き方を testing.md に明記した. - 移行前後で検出テスト名一覧(124 件,うち `[Explicit]` 2 件)が完全一致すること,キャストした 16 箇所がミューテーションで正しく失敗することを確認済み. - 後段: #27 はこのブランチの取り込み後にクローズしてよい.#31(PR #36)の実機確認待ちは継続. + +- [x] **ヘッドレスのセルフテストモードと検証プロファイルの導入(`/verify` で依存更新後の確認を自動化)** (2026-09-29): 依存更新(#44 のような System.* やバインディングリダイレクトの変更)後に,人間が実機を触る前に「起動時のアセンブリ解決エラーが無い・プレビュー経路が動く・校正〜保存が通る」を自動確認できるよう,`TIASshot.exe --selftest`(ビルドフラグ `SELFTEST`,Debug のみ)とランナー `scripts/verify/run_selftest.py`,`.claude/verify-profile.md` を追加した. + - 設計判断(2026-09-18 の「検証プロファイルを作らない」判断の見直し): verifier は UI 操作もカメラ操作もできないため,当時はハードウェア結合層を担保できないと判断した.今回は「アプリ自身にヘッドレス検査モードを持たせ,verifier はコマンドを叩いて終了コードを読むだけ」にすることで,実機 SDK 以外の部分(依存ロード・`CameraBase` の校正フレーム処理・`ShowImage`/`FastClone`・`PreviewMonitor`・`ChartDetector`〜`SaveImages`)を実コードのまま通せるようにした.実カメラの代わりは `CameraBase` を継承した偽カメラ,実チャートの代わりはコード生成の合成チャート(実画像はリポジトリに入れない). + - 失敗注入で検出力を確認済み(System.Memory.dll 除去・exe.config のリダイレクト改ざんで FAIL).カメラ SDK の欠落は WARN 扱いで判定しない. + - Phase 1b(verifier)を本プロジェクトで初めて実行し,ランナーが人間の Release 成果物まで消す・packages 退避失敗で終了コードが 2 にならない,の 2 点を人間に回る前に捕捉して直した. + - 後段: セルフテストが代替しない範囲(実カメラ SDK との結合・実プレビュー表示・実チャートの検出性能・画像の見た目・長時間のメモリ/GDI)は引き続き実機で人間が確認する.`Form1.ShowImage` 等が新しいコントロールを参照するようになった場合はヘッドレス用コンストラクタの追従が要る. diff --git a/scripts/verify/run_selftest.py b/scripts/verify/run_selftest.py new file mode 100644 index 0000000..b9c7af4 --- /dev/null +++ b/scripts/verify/run_selftest.py @@ -0,0 +1,212 @@ +"""TIASshot のヘッドレスセルフテストのランナー(/verify・verifier エージェント用). + +手順(すべてローカルのみ.実機カメラ・ネットワーク上のサービスには触れない): + 1. ランナーがビルドする構成(Debug|x64)の出力 bin/x64/Debug・obj/x64/Debug を削除する + (汚れた bin による依存漏れの見逃しを防ぐ.ENV_04「クリーン環境での検証」). + Release 等ほかの構成の出力(人間のビルド成果物)には触れない + --full のときは packages も退避して nuget restore からやり直す(依存更新時) + 2. Debug|x64 をビルドする(SELFTEST フラグ入り) + 3. 既存テスト(NUnit・x64)を実行する(--skip-tests で省略) + 4. TIASshot.exe --selftest を実行し,終了コードと結果ファイルで判定する + +出力: .verify/selftest/run-<日時>/ に build.log・test.log・selftest-result.txt・summary.json +終了コード: 0=全て合格,1=不合格あり,2=前提条件を満たさない(ツール未導入等) + +使い方(リポジトリルートで実行): + python scripts/verify/run_selftest.py # 通常(Debug 出力削除 → ビルド → テスト → セルフテスト) + python scripts/verify/run_selftest.py --full # 依存更新時(packages 退避 → nuget restore から) + python scripts/verify/run_selftest.py --no-clean # Debug 出力を消さない(開発中の素早い再確認用.判定には使わない) +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +SLN = REPO / "TIASshot.sln" +EXE = REPO / "TIASshot" / "bin" / "x64" / "Debug" / "TIASshot.exe" +TEST_DLL = REPO / "TIASshot.Tests" / "bin" / "x64" / "Debug" / "TIASshot.Tests.dll" +# クリーン対象はランナーがビルドする構成(Debug|x64)の出力だけ.bin 全体を消すと人間の Release 出力まで消える +CLEAN_DIRS = [REPO / p / d / "x64" / "Debug" for p in ("TIASshot", "TIASshot.Tests") for d in ("bin", "obj")] +SELFTEST_TIMEOUT_SEC = 420 # セルフテスト内部のウォッチドッグ(5 分)より長くする +BUILD_TIMEOUT_SEC = 1200 + +EXIT_OK = 0 +EXIT_FAIL = 1 +EXIT_PRECONDITION = 2 + + +class PreconditionError(Exception): + """前提条件を満たさない(verifier は「検証不能」と報告する).""" + + +def find_vs() -> Path: + vswhere = Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / \ + "Microsoft Visual Studio" / "Installer" / "vswhere.exe" + if not vswhere.exists(): + raise PreconditionError(f"vswhere.exe が見つかりません: {vswhere}(Visual Studio / Build Tools を導入してください)") + out = subprocess.run([str(vswhere), "-latest", "-products", "*", "-property", "installationPath"], + capture_output=True, text=True, check=False) + path = out.stdout.strip().splitlines()[0] if out.stdout.strip() else "" + if not path: + raise PreconditionError("vswhere が Visual Studio / Build Tools を検出できません") + return Path(path) + + +def find_nuget() -> str: + found = shutil.which("nuget") + if found: + return found + local = REPO / "nuget.exe" + if local.exists(): + return str(local) + raise PreconditionError("nuget.exe が見つかりません(setup.ps1 を実行するか PATH に追加してください)") + + +def run_logged(cmd: list[str], log: Path, timeout: int) -> int: + """コマンドを実行し,標準出力・標準エラーをログへ書く.戻り値は終了コード.""" + with log.open("w", encoding="utf-8", errors="replace") as f: + f.write("> " + " ".join(cmd) + "\n\n") + f.flush() + proc = subprocess.run(cmd, cwd=REPO, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=timeout, check=False) + f.write(decode_output(proc.stdout)) + return proc.returncode + + +def decode_output(data: bytes) -> str: + """ツールの出力を復号する(MSBuild / vstest はリダイレクト時 UTF-8.古い環境の cp932 にも備える).""" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp932", errors="replace") + + +def clean_build_outputs() -> None: + for d in CLEAN_DIRS: + if d.exists(): + try: + shutil.rmtree(d) + except OSError as ex: + raise PreconditionError( + f"{d} を削除できません(TIASshot.exe や Visual Studio が使用中の可能性): {ex}") from ex + + +def restore_packages_clean(run_dir: Path, nuget: str) -> tuple[bool, str]: + """packages を退避して nuget restore からやり直す(clone 直後と同じ状態を再現する).""" + packages = REPO / "packages" + backup = REPO / f"packages.bak-{run_dir.name}" + if packages.exists(): + try: + # 改名は原子的.失敗時は packages が元のまま残る(中途半端な状態にならない) + packages.rename(backup) + except OSError as ex: + raise PreconditionError( + f"packages を退避できません(Visual Studio 等が使用中の可能性.packages は変更していません): {ex}") from ex + code = run_logged([nuget, "restore", str(SLN)], run_dir / "restore.log", BUILD_TIMEOUT_SEC) + if code != 0: + # 復元に失敗したら退避した packages を戻す(人間の作業環境を壊さない) + if packages.exists(): + shutil.rmtree(packages, ignore_errors=True) + if backup.exists(): + backup.rename(packages) + return False, f"nuget restore 失敗(exit {code}).packages は元に戻しました" + if backup.exists(): + shutil.rmtree(backup, ignore_errors=True) + return True, "nuget restore 成功(packages を再取得)" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--full", action="store_true", help="packages を退避して nuget restore からやり直す(依存更新時)") + parser.add_argument("--no-clean", action="store_true", help="Debug 出力(bin/obj の x64/Debug)を削除しない(開発時の短縮用.判定には使わない)") + parser.add_argument("--skip-tests", action="store_true", help="既存テスト(vstest)を実行しない(開発時の短縮用.判定には使わない)") + args = parser.parse_args() + # verifier(Git Bash 経由)が読めるよう,本ランナーの出力は UTF-8 に固定する + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + run_dir = REPO / ".verify" / "selftest" / f"run-{stamp}" + run_dir.mkdir(parents=True, exist_ok=True) + summary: dict = {"run_dir": str(run_dir), "steps": [], "result": None} + + def step(name: str, ok: bool, detail: str) -> None: + summary["steps"].append({"name": name, "ok": ok, "detail": detail}) + print(f"[{'OK' if ok else 'NG'}] {name}: {detail}", flush=True) + + exit_code = EXIT_OK + try: + vs = find_vs() + msbuild = vs / "MSBuild" / "Current" / "Bin" / "MSBuild.exe" + vstest = vs / "Common7" / "IDE" / "CommonExtensions" / "Microsoft" / "TestWindow" / "vstest.console.exe" + if not msbuild.exists(): + raise PreconditionError(f"MSBuild.exe が見つかりません: {msbuild}") + + if not args.no_clean: + clean_build_outputs() + step("clean", True, "Debug 出力(bin/obj の x64/Debug)を削除") + + if args.full: + ok, detail = restore_packages_clean(run_dir, find_nuget()) + step("restore", ok, detail) + if not ok: + raise PreconditionError(detail) + + code = run_logged([str(msbuild), str(SLN), "/t:Build", "/p:Configuration=Debug", "/p:Platform=x64", + "/nologo", "/v:minimal"], run_dir / "build.log", BUILD_TIMEOUT_SEC) + step("build", code == 0, f"Debug|x64 ビルド exit {code}(build.log)") + if code != 0: + exit_code = EXIT_FAIL + raise RuntimeError("ビルド失敗") + + if not args.skip_tests: + if not vstest.exists(): + raise PreconditionError(f"vstest.console.exe が見つかりません: {vstest}") + code = run_logged([str(vstest), str(TEST_DLL), "/Platform:x64"], run_dir / "test.log", BUILD_TIMEOUT_SEC) + step("tests", code == 0, f"既存テスト exit {code}(test.log)") + if code != 0: + exit_code = EXIT_FAIL + + result_file = run_dir / "selftest-result.txt" + try: + proc = subprocess.run([str(EXE), "--selftest", "--selftest-out", str(result_file)], cwd=REPO, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=SELFTEST_TIMEOUT_SEC, check=False) + code = proc.returncode + except subprocess.TimeoutExpired: + code = None + text = result_file.read_text(encoding="utf-8-sig", errors="replace") if result_file.exists() else "" + heads = [ln for ln in text.splitlines() if ln.startswith("[") or ln.startswith("RESULT:")] + summary["selftest_exit_code"] = code + summary["selftest_lines"] = heads + step("selftest", code == 0, f"exit {code}(selftest-result.txt)") + for ln in heads: + print(f" {ln}", flush=True) + if code != 0: + exit_code = EXIT_FAIL + except PreconditionError as ex: + step("precondition", False, str(ex)) + exit_code = EXIT_PRECONDITION + except RuntimeError as ex: + step("abort", False, str(ex)) + exit_code = EXIT_FAIL + except subprocess.TimeoutExpired as ex: + step("timeout", False, f"{ex.cmd} が {ex.timeout} 秒で終わりません") + exit_code = EXIT_FAIL + + summary["result"] = {EXIT_OK: "PASS", EXIT_FAIL: "FAIL", EXIT_PRECONDITION: "PRECONDITION"}[exit_code] + (run_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"RESULT: {summary['result']} (出力: {run_dir})", flush=True) + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify/test_run_selftest.py b/scripts/verify/test_run_selftest.py new file mode 100644 index 0000000..4f142b8 --- /dev/null +++ b/scripts/verify/test_run_selftest.py @@ -0,0 +1,138 @@ +"""run_selftest.py のテスト(python -m unittest scripts/verify/test_run_selftest.py). + +仕様(ENV_04「セルフテスト」・.claude/verify-profile.md): + - ランナーがクリーンするのは Debug 出力(TIASshot/ と TIASshot.Tests/ の bin/x64/Debug・obj/x64/Debug)のみ. + bin/x64/Release 等ほかの構成の出力は消さない + - --full で packages の退避(改名)自体に失敗した場合は packages を変更せずに終了コード 2 で止まる +実ビルド・実行は行わず,一時ディレクトリ上で検証する. +""" + +from __future__ import annotations + +import importlib.util +import shutil +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +_HERE = Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location("run_selftest", _HERE / "run_selftest.py") +rs = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(rs) + + +class CleanDirsTest(unittest.TestCase): + def test_clean_dirs_are_exactly_debug_x64_outputs_of_both_projects(self): + rel = sorted(str(p.relative_to(rs.REPO)).replace("\\", "/") for p in rs.CLEAN_DIRS) + self.assertEqual(rel, sorted([ + "TIASshot/bin/x64/Debug", "TIASshot/obj/x64/Debug", + "TIASshot.Tests/bin/x64/Debug", "TIASshot.Tests/obj/x64/Debug", + ])) + + def test_clean_removes_only_debug_outputs(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + keep = [ + root / "TIASshot" / "bin" / "x64" / "Release" / "TIASshot.exe", + root / "TIASshot" / "obj" / "x64" / "Release" / "a.cache", + root / "TIASshot.Tests" / "bin" / "x64" / "Release" / "t.dll", + root / "TIASshot" / "bin" / "Debug" / "anycpu.exe", + root / "TIASshot" / "Program.cs", + root / "packages" / "p.nupkg", + ] + remove = [root / p.relative_to(rs.REPO) / "x.dll" for p in rs.CLEAN_DIRS] + for f in keep + remove: + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text("x") + with mock.patch.object(rs, "CLEAN_DIRS", [root / p.relative_to(rs.REPO) for p in rs.CLEAN_DIRS]): + rs.clean_build_outputs() + for f in keep: + self.assertTrue(f.exists(), f"消してはいけない: {f}") + for f in remove: + self.assertFalse(f.parent.exists(), f"消すべき: {f.parent}") + + def test_clean_with_missing_dirs_is_noop(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with mock.patch.object(rs, "CLEAN_DIRS", [root / p.relative_to(rs.REPO) for p in rs.CLEAN_DIRS]): + rs.clean_build_outputs() # 例外にならない + + def test_clean_failure_is_precondition_error(self): + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) / "TIASshot" / "bin" / "x64" / "Debug" + d.mkdir(parents=True) + with mock.patch.object(rs, "CLEAN_DIRS", [d]), \ + mock.patch.object(rs.shutil, "rmtree", side_effect=PermissionError("in use")): + with self.assertRaises(rs.PreconditionError): + rs.clean_build_outputs() + + +class PackagesRenameFailureTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.mkdtemp() + self.root = Path(self._tmp) + self.packages = self.root / "packages" + (self.packages / "OpenCvSharp4").mkdir(parents=True) + (self.packages / "OpenCvSharp4" / "x.nupkg").write_text("x") + vs = self.root / "vs" + msbuild = vs / "MSBuild" / "Current" / "Bin" / "MSBuild.exe" + msbuild.parent.mkdir(parents=True) + msbuild.write_text("") + self.vs = vs + + def tearDown(self): + shutil.rmtree(self._tmp, ignore_errors=True) + + def _run_main(self, argv): + real_rename = Path.rename + + def failing_rename(self_path, target): + if Path(self_path).name == "packages": + raise PermissionError("使用中") + return real_rename(self_path, target) + + run_logged = mock.Mock(return_value=0) + with mock.patch.object(rs, "REPO", self.root), \ + mock.patch.object(rs, "SLN", self.root / "TIASshot.sln"), \ + mock.patch.object(rs, "find_vs", return_value=self.vs), \ + mock.patch.object(rs, "find_nuget", return_value="nuget"), \ + mock.patch.object(rs, "run_logged", run_logged), \ + mock.patch.object(rs.subprocess, "run", side_effect=AssertionError("実行してはいけない")), \ + mock.patch.object(Path, "rename", failing_rename), \ + mock.patch.object(sys, "argv", ["run_selftest.py"] + argv), \ + mock.patch("sys.stdout", new_callable=_Sink): + code = rs.main() + return code, run_logged + + def test_rename_failure_exits_2_and_keeps_packages(self): + code, run_logged = self._run_main(["--full", "--no-clean"]) + self.assertEqual(code, rs.EXIT_PRECONDITION) + self.assertEqual(code, 2) + self.assertTrue((self.packages / "OpenCvSharp4" / "x.nupkg").exists(), "packages は変更しない") + self.assertEqual(list(self.root.glob("packages.bak-*")), [], "退避先を作らない") + run_logged.assert_not_called() # restore・ビルドに進まない + + def test_rename_failure_summary_is_precondition(self): + import json + self._run_main(["--full", "--no-clean"]) + summaries = list((self.root / ".verify" / "selftest").glob("run-*/summary.json")) + self.assertEqual(len(summaries), 1) + data = json.loads(summaries[0].read_text(encoding="utf-8")) + self.assertEqual(data["result"], "PRECONDITION") + self.assertEqual(data["steps"][-1]["name"], "precondition") + + +class _Sink: + """print の出力を捨てる(reconfigure を持たない stdout の代わり).""" + + def write(self, s): + return len(s) + + def flush(self): + pass + + +if __name__ == "__main__": + unittest.main()