diff --git a/scripts/verify/run_hwtest.py b/scripts/verify/run_hwtest.py new file mode 100644 index 0000000..c5c279f --- /dev/null +++ b/scripts/verify/run_hwtest.py @@ -0,0 +1,140 @@ +"""TIASshot の実機テストのランナー(PLAN_06.ユーザーが会話の中で実機テストを合図したときだけ実行する). + +実機のカメラ(Lumenera / ImagingSource)と光源(シリアルポート)を操作する.撮影の本番中に実行しないこと. +NUnit テスト・セルフテストは実行しない(それぞれ run_tests.py・run_selftest.py). + +手順: + 1. ランナーがビルドする構成(Debug|x64)の出力 bin/x64/Debug・obj/x64/Debug を削除する(--no-clean で省略) + 2. Debug|x64 をビルドする(HWTEST フラグ入り) + 3. TIASshot.exe が起動していないことを確かめる(起動中はカメラ・光源を使用中=撮影の本番中の可能性があるため実行しない) + 4. TIASshot.exe --hwtest --camera <機種> を実行ファイルのフォルダで実行し,終了コードと結果ファイルで判定する + (実際のフォームが表示される.保存先は %TEMP%\\TIASshot-hwtest\\work-* に差し替えられる) + +出力: .verify/hwtest/run-<日時>/ に build.log・hwtest-result.txt・summary.json +終了コード: 0=全項目合格,1=不合格あり(ビルド失敗・検査の不合格・タイムアウト), + 2=前提条件を満たさない(ツール未導入・TIASshot 起動中・指定機種が見つからない・引数不正・--with-chart(未実装)) + +使い方(リポジトリルートで実行): + python scripts/verify/run_hwtest.py --camera lucam + python scripts/verify/run_hwtest.py --camera iscam + python scripts/verify/run_hwtest.py --camera iscam --no-clean # Debug 出力を消さない(開発時の短縮用) +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import subprocess +import sys + +from run_selftest import (BUILD_TIMEOUT_SEC, EXE, EXIT_FAIL, EXIT_OK, EXIT_PRECONDITION, REPO, SLN, + PreconditionError, clean_build_outputs, find_vs, run_logged) + +# 実機テスト内部のウォッチドッグ(全体 10 分)より長くする.超えたらランナーがプロセスを打ち切る +HWTEST_TIMEOUT_SEC = 720 + +# 実機テスト(TIASshot.exe --hwtest)の終了コード +HW_PASS = 0 +HW_FAIL = 1 +HW_ERROR = 2 +HW_TIMEOUT = 3 + + +def is_tiasshot_running() -> bool: + """TIASshot.exe が起動しているか(人間が撮影中ならカメラ・光源を使用中のため実機テストを行わない).""" + proc = subprocess.run(["tasklist", "/FI", "IMAGENAME eq TIASshot.exe", "/FO", "CSV", "/NH"], + capture_output=True, check=False) + out = proc.stdout.decode("cp932", errors="replace") + return "tiasshot.exe" in out.lower() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--camera", required=True, choices=["lucam", "iscam"], help="検査するカメラの機種") + parser.add_argument("--with-chart", action="store_true", + help="チャートが必要な検査も行う(PLAN_06 フェーズ 2.未実装のため終了コード 2 になる)") + parser.add_argument("--no-clean", action="store_true", + help="Debug 出力(bin/obj の x64/Debug)を削除しない(開発時の短縮用)") + args = parser.parse_args() + # エージェント(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" / "hwtest" / f"run-{stamp}" + run_dir.mkdir(parents=True, exist_ok=True) + summary: dict = {"run_dir": str(run_dir), "camera": args.camera, "with_chart": args.with_chart, + "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" + if not msbuild.exists(): + raise PreconditionError(f"MSBuild.exe が見つかりません: {msbuild}") + + if is_tiasshot_running(): + raise PreconditionError("TIASshot.exe が起動しています(撮影の本番中の可能性.カメラ・光源を使用中のため実機テストを行いません)") + + if not args.no_clean: + clean_build_outputs() + step("clean", True, "Debug 出力(bin/obj の x64/Debug)を削除") + + 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("ビルド失敗") + + result_file = run_dir / "hwtest-result.txt" + cmd = [str(EXE), "--hwtest", "--camera", args.camera, "--hwtest-out", str(result_file)] + if args.with_chart: + cmd.append("--with-chart") + summary["command"] = " ".join(cmd) + try: + # デバイス状態ファイル({DeviceName}.xml)は相対パスで読まれるため,通常起動と同じく実行ファイルのフォルダで実行する + proc = subprocess.run(cmd, cwd=EXE.parent, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=HWTEST_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["hwtest_exit_code"] = code + summary["hwtest_lines"] = heads + if code is None: + detail = f"{HWTEST_TIMEOUT_SEC} 秒で終わらないためランナーが打ち切りました(hwtest-result.txt)" + else: + detail = f"exit {code}(hwtest-result.txt)" + step("hwtest", code == HW_PASS, detail) + for ln in heads: + print(f" {ln}", flush=True) + if code == HW_ERROR: + # 指定機種が見つからない・引数不正・未実装の指定 → 実機テスト自体を行えない(検証不能) + exit_code = EXIT_PRECONDITION + elif code != HW_PASS: + 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())