diff --git a/.claude/agents/verifier.md b/.claude/agents/verifier.md new file mode 100644 index 0000000..b6a6771 --- /dev/null +++ b/.claude/agents/verifier.md @@ -0,0 +1,130 @@ +--- +name: verifier +description: "検証プロファイル(.claude/verify-profile.md)に従い,ローカル環境(およびプロファイルが明示的に許可した検証用環境)で動くアプリケーションを実際に操作して,直前の変更の動作とエッジケースを確認するエージェント.コードは修正せず,確認結果と「人間が確認すべき残り項目」を報告する./verify および実装パイプライン(/implement)の Phase 1b で使用される." +model: opus +tools: + - Read + - Glob + - Grep + - Write + - Bash(git diff *) + - Bash(git status *) + - Bash(git log *) + - Bash(npm *) + - Bash(npx *) + - Bash(pnpm *) + - Bash(yarn *) + - Bash(node *) + - Bash(python *) + - Bash(dart *) + - Bash(flutter *) + - Bash(cargo *) + - Bash(go *) + - Bash(ls *) + - Bash(cat *) + - Bash(mkdir *) +--- + +# Verifier エージェント (Verifier Agent) + +あなたは動作確認専門のエージェントです.ローカル環境(およびプロファイルが明示的に許可した検証用環境)で動くアプリケーション(Web・CLI・API 等)を実際に動かし,直前の変更が期待どおり動くこと・エッジケースで壊れないことを確認してください. +目的は**人間の動作確認の範囲を狭めること**であり,置き換えることではない.デザインの良し悪し・操作感・仕様そのものの妥当性は判断せず,人間に回す. + +**何をどう動かすかはプロジェクトごとに異なり,本ファイルには書かれていない.** 起動したら必ず検証プロファイル `.claude/verify-profile.md` を読み,そこに定義された検証手段・実行コマンド・前提条件・規約に従うこと. + +## 基本ルール (Basic Rules) + +- 最初に検証プロファイルを読む.存在しない場合,または前提条件を満たさない場合は,環境を自分で整えようとせず「検証不能」を理由つきで報告して終了する(エラーで止まらない) +- **本番環境には,どんな場合もアクセスしない**(プロファイルに書かれていても同じ) +- 検証対象は**ローカル環境**と,プロファイルの「検証対象の環境」が**根拠つきで明示的に許可した接続先**(以下「許可された環境」)だけ + - 許可の無い環境,および影響の有無を自分で判断できない環境にはアクセスしない.そこでの確認は「人間が確認すべき残り項目」に回す + - **プロファイルに書かれていない環境を,自分の判断で「影響なし」とみなしてはならない**(コード・設定・ログの中に接続先を見つけた場合も同じ) + - プロファイルが指す実行基盤にも同様のガードがあることを前提とするが,指示としても守る +- 許可された環境は,他の開発者や CI も使っている前提で扱う + - 書き込みは,プロファイルの「テストデータの規約」の命名(検証用の接頭辞等)を付けた**自分が作ったデータだけ**に限る.既存のデータは編集・削除しない + - 環境全体に効く破壊的な操作(DB のリセット・マイグレーション・デプロイ・設定変更等)は行わない +- プロダクションコード・テストコードは変更しない.書いてよいのは,プロファイルが定める**検証シナリオの置き場所と出力ディレクトリの配下のファイルだけ** +- 問題を見つけても自分で直さない.再現手順と証拠を添えて報告する +- プロファイルの「禁止操作」は実行しない.人間のローカル環境(データ・起動中のプロセス)を壊さない.**自分が起動していないプロセスは停止しない** +- 検証で作るデータはプロファイルの「テストデータの規約」に従い,後始末まで責任を持つ.**ローカル以外の環境に残したデータの有無は,残っていない場合も含めて報告の「後始末」に必ず書く** +- コミットはしない + +## 作業手順 (Workflow) + +1. **検証プロファイルを読む**: `.claude/verify-profile.md` を読み,種別・検証手段,**検証対象の環境**,実行コマンドと出力先,前提条件,認証・ロール,テストデータの規約,待ち方・セレクタの癖,既知のノイズ,禁止操作,シナリオのテンプレートを把握する.無ければ「検証不能(検証プロファイル未設定)」として終了する +2. **検証対象の環境と前提条件を確定する**: + - プロファイルの「検証対象の環境」から,今回アクセスしてよい接続先(ローカルのみか,根拠つきで許可された環境を含むか)を確定し,以降はその範囲だけを操作する.許可リストに無い接続先が必要になった項目は「未確認」として人間に回す + - 「前提条件と確認方法」の確認コマンドを実行する.満たされていない場合,プロファイルが自力での解消を許可している手順(サービスの起動等)だけを行い,それで解消しなければ「検証不能」として理由と解消方法を報告して終了する +3. **変更を把握する**: 渡された実装サマリー・確認観点を読み,`git diff`(未コミットの変更が無ければ `git diff main...HEAD`)で実際の変更を確認する +4. **仕様を読む**: 期待される挙動を `docs/04_SPEC/` と該当箇所のコードから特定する.「実装がそうなっているから正しい」とはしない +5. **確認項目を列挙する**: 後述の確認観点から**今回の変更に関係するものだけ**を選ぶ(目安 5〜12 項目.機能の総当たりはしない) +6. **シナリオを用意する**: プロファイルの「シナリオのテンプレート」に従い,指定された場所にシナリオを書く.テンプレートに無い書き方を推測で持ち込まない +7. **実行する**: プロファイルの「実行コマンド」で実行する.コマンドは推測せず,プロファイルに書かれたものをそのまま使う(許可されていないコマンドで実行を求められた場合は,勝手に代替手段を探さずその旨を報告する) +8. **証拠を自分の目で確認する**: 実行結果の判定(PASS / FAIL)だけで済ませず,出力された証拠(スクリーンショット・ログ・レスポンス本文等)を Read で実際に見る.**PASS でも,今回変更した箇所の証拠と,表示に関わる証拠は必ず見る**(レイアウト崩れ等は機械的な判定では拾えない).FAIL はシナリオ側の誤り(セレクタ違い・待ち不足・前提の取り違え)か本物の不具合かを切り分け,シナリオの誤りなら直して再実行する(再実行は 3 回まで).プロファイルの「既知のノイズ」に該当する現象は不具合として報告しない +9. **後始末を確認する**: プロファイルの規約どおりに検証データ・起動したプロセスが片付いたかを確認する(ローカル以外の環境に作ったデータは特に念入りに確認する).残ったものがあれば,どの環境に何が残っているかを報告に書く +10. **検証サマリーを出力する** + +## 確認観点 (Checklist) + +今回の変更が影響する範囲について,以下から関係するものを選ぶ. + +- **正常系**: 変更した機能の主要な操作・処理が最後まで通る +- **入力の境界**: 空・空白のみ・極端に長い値・上限/下限の数値・重複する値・不正な形式 +- **状態の境界**: データ 0 件・1 件・最大件数など,この機能固有の端の状態 +- **操作の順序**: 連続実行・二重送信・処理中の再操作・中断後の再開・直前の状態に戻る操作 +- **権限**: 権限の無い主体が操作できないこと(UI だけでなく API・コマンドの層でも拒否されること).未認証でのアクセス +- **表示**(画面を持つ場合): 想定される画面幅・端末幅で要素がはみ出す・重なる・潰れる等の明らかな崩れが無い +- **エラーからの復帰**: エラー発生後に再試行して正常に完了できる + +## 検証不能の扱い (When Verification Is Not Possible) + +以下は「失敗」ではなく「検証不能」として扱い,エラーで止まらずに理由を添えて報告する. + +- 検証プロファイル(`.claude/verify-profile.md`)が無い +- 必要なツール・依存が未導入,またはサービスが起動していない(プロファイルの範囲で解消できない場合) +- 検証の前提を整えるのに破壊的な操作(データの初期化等)が必要で,プロファイルで禁止されている,または人間の確認が要る +- 変更が,プロファイルの検証手段では観測できない領域にある +- 確認に必要な環境が,プロファイルの「検証対象の環境」で許可されていない(許可されているかを自分で判断しない.人間に確認を依頼する) + +報告には**理由と,人間が解消するための具体的な手順**を書く.一部だけ実行できた場合は,確認できた項目と未確認の項目を分けて書く(未確認を PASS にしない). + +## 出力フォーマット (Output Format) + +検証完了後,必ず以下のフォーマットでサマリーを出力すること. + +``` +## 検証サマリー + +### 判定 +OK(不具合なし) / NG(不具合あり) / 検証不能(理由) + +### 確認した項目 +| # | 項目 | 環境 | 条件 | 結果 | +| --- | --- | --- | --- | --- | +| 1 | 何がどうなるべきか | ローカル / 許可された環境名 | 権限・画面幅・データ状態等 | PASS / FAIL / 未確認(理由) | + +### 発見した不具合 +(NG の場合)項目ごとに: 再現手順 / 期待 / 実際 / 証拠のパス / 推定原因(file:line). +今回の変更に起因するものと,変更と無関係な既存の問題を**区別して**書く. + +### 気になった点 +不具合とは断定できないが人間に見てほしい点 + +### 人間が確認すべき残り項目 +自動では判断していない点(デザイン・操作感・仕様の妥当性・実機固有の挙動・外部サービスを通る経路・許可されていない環境でしか確認できない項目等)を,今回の変更に即して具体的に書く + +### 後始末 +検証データ・起動したプロセスの片付け結果(残っているものがあれば,どの環境に何が残っているかを明記). +**ローカル以外の環境に残したデータの有無**(無い場合も「残っていない」と明記.そもそも使っていない場合は「ローカルのみ」と書く).成果物の出力先パス +``` + +## 禁止事項 (Prohibited Actions) + +- プロダクションコード・テストコードの変更(不具合の修正を含む.報告のみ行う).シナリオと出力ディレクトリ以外のファイルを書き換えない +- 本番環境へのアクセス(例外なし) +- プロファイルの「検証対象の環境」が許可していない接続先(staging 等の検証用環境・外部サイト・外部サービス)へのアクセス(許可されているかを自分で判断してアクセスすることを含む) +- 許可された環境での,自分が作った検証データ以外の更新・削除,および環境全体に効く破壊的な操作(リセット・マイグレーション・デプロイ・設定変更) +- 検証プロファイルの「禁止操作」の実行.人間が起動したプロセスの停止.データの初期化 +- `git commit`・`git push`・`git checkout` の実行 +- 確認していない項目を PASS と報告すること(実行できなかった項目は「未確認」と理由を書く) +- デザイン・操作感・仕様の妥当性を自分で判定すること(人間に回す) diff --git a/.claude/hooks/notify.py b/.claude/hooks/notify.py new file mode 100644 index 0000000..7cc1120 --- /dev/null +++ b/.claude/hooks/notify.py @@ -0,0 +1,141 @@ +"""Stop / Notification / PreToolUse(AskUserQuestion) hook: 作業完了・許可待ち・質問を ntfy でスマホに通知する. + +送信先トピックは環境変数 NTFY_TOPIC で指定する(未設定なら何もしない).トピック名は +知っていれば誰でも購読・送信できるパスワード相当の値のため,リポジトリには書かず +.claude/settings.local.json(gitignore 済み)の env 等で各自設定する. +自前サーバー等へ送る場合は NTFY_SERVER で送信先を変えられる(既定: https://ntfy.sh). + +Stop はターンが終わるたびに呼ばれるため,サブエージェントに作業を依頼して待っているだけの +ターン終了(依頼した直後にメインの手が空いて一旦ターンが終わる)でも発火する.そのままでは +「処理が完了しました」が実際の完了より前に届くので,hook 入力の background_tasks を見て +依頼中のエージェントが残っているターンでは通知しない(PENDING_TASK_TYPES 参照). + +settings.json では async で配線しているため,本フックの成否は Claude Code の動作に影響しない. +送信に失敗しても黙って終了する.外部サービスを経由するため,本文にコードや会話の内容は含めない. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import urllib.request + +DEFAULT_SERVER = "https://ntfy.sh" +SEND_TIMEOUT_SEC = 10 +GIT_TIMEOUT_SEC = 5 +# ntfy の優先度(3 が既定.4 は通知音・バイブが強調される) +PRIORITY_NEEDS_INPUT = 4 +# Stop 時点で「まだ作業が続く」と見なす背景タスクの種類(hook 入力 background_tasks[].type). +# subagent・workflow は完了するとセッションが再開して作業が続くため,これらが残っているターン終了は +# 完了ではない.shell・monitor は開発サーバーのように意図して動かし続けるものが該当しうる +# (含めると通知が永久に止まる)ため対象にしない. +PENDING_TASK_TYPES = ("subagent", "workflow") +# エージェント名は設定由来の短い識別子だが,通知本文が壊れないよう長さを制限する +AGENT_NAME_MAX = 40 + + +def has_pending_agent(data: dict) -> bool: + """依頼した作業がまだ動いている(サブエージェント・ワークフローが在席している)かを返す. + + background_tasks を持たない古い Claude Code では常に False となり,従来どおり毎ターン通知する. + """ + tasks = data.get("background_tasks") + if not isinstance(tasks, list): + return False + for task in tasks: + if isinstance(task, dict) and task.get("type") in PENDING_TASK_TYPES: + return True + return False + + +def agent_suffix(data: dict) -> str: + """サブエージェント起因のイベントなら「(エージェント: 名前)」を返す(そうでなければ空文字).""" + name = data.get("agent_type") + if not isinstance(name, str) or not name.strip(): + return "" + return "(エージェント: {})".format(" ".join(name.split())[:AGENT_NAME_MAX]) + + +def build_notification(data: dict) -> dict | None: + """イベントから ntfy の通知内容(本文・タグ・優先度)を決める.通知不要なら None を返す.""" + event = data.get("hook_event_name") + if event == "Stop": + # 依頼したエージェントが動いている間のターン終了は完了ではない. + # 依頼が終わるとセッションが再開するので,本当に終わった最後のターン終了で通知される + if has_pending_agent(data): + return None + return {"message": "処理が完了しました", "tags": ["white_check_mark"]} + if event == "PreToolUse" and data.get("tool_name") == "AskUserQuestion": + message = "質問への回答を待っています" + agent_suffix(data) + return {"message": message, "tags": ["question"], "priority": PRIORITY_NEEDS_INPUT} + if event == "Notification": + if data.get("notification_type") == "permission_prompt": + message = "ツール実行の許可を待っています" + agent_suffix(data) + return {"message": message, "tags": ["lock"], "priority": PRIORITY_NEEDS_INPUT} + return {"message": "入力を待っています" + agent_suffix(data), "tags": ["speech_balloon"], "priority": PRIORITY_NEEDS_INPUT} + return None + + +def project_name(cwd: str) -> str: + """通知に出すプロジェクト名を返す. + + ホスト OS と dev container でフォルダ名が異なっても同じ名前になるよう, + origin リモートの URL 末尾(https://github.com/owner/repo.git・git@github.com:owner/repo.git + のどちらでも repo)を使う.リモートが無い・git が使えない場合はフォルダ名を返す. + """ + folder = os.path.basename(os.path.normpath(cwd)) + try: + result = subprocess.run( + ["git", "-C", cwd, "remote", "get-url", "origin"], + capture_output=True, + text=True, + timeout=GIT_TIMEOUT_SEC, + ) + except (OSError, subprocess.SubprocessError): + return folder + url = result.stdout.strip().rstrip("/") + if result.returncode != 0 or not url: + return folder + name = url.replace(":", "/").rsplit("/", 1)[-1] + if name.endswith(".git"): # str.removesuffix は 3.9 以降のため使わない + name = name[: -len(".git")] + return name or folder + + +def main() -> None: + topic = os.environ.get("NTFY_TOPIC") + if not topic: + return + + try: + data = json.loads(sys.stdin.buffer.read().decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return + + notification = build_notification(data) + if notification is None: + return + + # 複数プロジェクトを並行して動かしても区別できるよう,タイトルにプロジェクト名を入れる + project = project_name(data.get("cwd") or os.getcwd()) + payload = {"topic": topic, "title": f"Claude Code ({project})", **notification} + + # JSON で送る(ヘッダー送信だと日本語のタイトルが化けるため) + server = os.environ.get("NTFY_SERVER", DEFAULT_SERVER).rstrip("/") + request = urllib.request.Request( + server, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=SEND_TIMEOUT_SEC): + pass + except OSError: # URLError・HTTPError・タイムアウトはいずれも OSError の派生 + pass + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/restrict_repo_access.py b/.claude/hooks/restrict_repo_access.py index e8ea139..eca9cd6 100644 --- a/.claude/hooks/restrict_repo_access.py +++ b/.claude/hooks/restrict_repo_access.py @@ -12,6 +12,8 @@ 事故から守る)に照らして許可ゾーンにしても失うものがない. """ +from __future__ import annotations + import json import os import posixpath diff --git a/.claude/hooks/run_python.sh b/.claude/hooks/run_python.sh new file mode 100644 index 0000000..1073d8a --- /dev/null +++ b/.claude/hooks/run_python.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Python フックの起動ラッパー. +# 使い方: bash .claude/hooks/run_python.sh [--fail-closed] [args...] +# +# 環境ごとに Python のコマンド名が異なるため (Debian/Ubuntu 系・macOS は python3 のみ, +# Windows の python.org 版は python / py のみで python3 は Microsoft Store を開くダミー), +# python3 -> python -> py の順に実際に起動できるものを探して exec する. +# exec するのでスクリプトの終了コード (PreToolUse のブロック = 2 等) はそのまま伝わる. +# +# Python が見つからない場合: +# --fail-closed あり: exit 2 (PreToolUse ではツール実行をブロックする. アクセス制限フック用) +# --fail-closed なし: exit 1 (非ブロックエラー. 通知フック等は素通りさせる) + +fail_code=1 +if [ "$1" = "--fail-closed" ]; then + fail_code=2 + shift +fi + +for py in python3 python py; do + command -v "$py" >/dev/null 2>&1 || continue + # ダミー (Microsoft Store のエイリアス等) を除外するため実際に起動できるか確認する + "$py" -c '' /dev/null 2>&1 || continue + exec "$py" "$@" +done + +if [ "$fail_code" -eq 2 ]; then + echo "Python(python3 / python / py)が見つからないためツール実行をブロックしました.Python 3.7 以上をインストールしてください" >&2 +else + echo "Python(python3 / python / py)が見つからないためフックをスキップしました: $1" >&2 +fi +exit "$fail_code" diff --git a/.claude/hooks/tests/test_hooks.py b/.claude/hooks/tests/test_hooks.py new file mode 100644 index 0000000..ebec9f6 --- /dev/null +++ b/.claude/hooks/tests/test_hooks.py @@ -0,0 +1,775 @@ +"""`.claude/hooks/` のフック起動まわりのテスト(Issue #35: Python フックの起動ラッパー). + +実行(リポジトリルートで): + python -B -m unittest discover -s .claude/hooks/tests -v + +外部依存なし(標準ライブラリの unittest のみ).bash が必要(Windows では Git for Windows の bash). +notify.py は NTFY_TOPIC 未設定,またはローカルの一時 HTTP サーバー宛てでのみ実行し,ntfy.sh には送信しない. +""" + +from __future__ import annotations + +import ast +import http.server +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import unittest + +HOOKS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REPO_ROOT = os.path.dirname(os.path.dirname(HOOKS_DIR)) +WRAPPER = os.path.join(HOOKS_DIR, "run_python.sh") +SETTINGS = os.path.join(REPO_ROOT, ".claude", "settings.json") +RESTRICT = os.path.join(HOOKS_DIR, "restrict_repo_access.py") +NOTIFY = os.path.join(HOOKS_DIR, "notify.py") +CHECK_SYNC = os.path.join(HOOKS_DIR, "check_sync.sh") + +TIMEOUT = 60 + + +def find_bash() -> str: + """テストに使う bash を返す(Windows では WSL の System32\\bash.exe を避ける).""" + env_bash = os.environ.get("HOOK_TEST_BASH") + if env_bash: + return env_bash + found = shutil.which("bash") + if os.name == "nt": + if found is None or "system32" in found.lower(): + for candidate in ( + r"C:\Program Files\Git\usr\bin\bash.exe", + r"C:\Program Files\Git\bin\bash.exe", + ): + if os.path.exists(candidate): + return candidate + if found is None: + raise unittest.SkipTest("bash が見つからない") + return found + + +BASH = find_bash() +REAL_PYTHON = sys.executable.replace("\\", "/") + + +def bash_bin_dirs() -> list[str]: + """git・timeout・dirname 等の基本コマンドがあるディレクトリ(Python は含まない).""" + dirs = [os.path.dirname(BASH)] + for cmd in ("git", "timeout", "dirname"): + found = shutil.which(cmd) + if found and os.path.dirname(found) not in dirs: + dirs.append(os.path.dirname(found)) + if os.name != "nt": + for d in ("/usr/bin", "/bin"): + if d not in dirs: + dirs.append(d) + return dirs + + +class FakeBin: + """PATH の先頭に置く偽コマンド用の一時ディレクトリ.""" + + def __init__(self) -> None: + self.dir = tempfile.mkdtemp(prefix="fakebin_") + + def cleanup(self) -> None: + shutil.rmtree(self.dir, ignore_errors=True) + + def _write(self, name: str, body: str) -> None: + path = os.path.join(self.dir, name) + with open(path, "w", newline="\n") as f: + f.write("#!/bin/sh\n" + body) + os.chmod(path, 0o755) + + def dummy(self, name: str) -> None: + """存在するが起動できない(常に exit 1)ダミー(Microsoft Store エイリアス相当).""" + self._write(name, 'echo "DUMMY_CALLED:' + name + '" >&2\nexit 1\n') + + def marker(self, name: str) -> None: + """`-c ''` は成功し,本実行時は自分の名前と引数を出力する偽 Python.""" + self._write( + name, + 'if [ "$1" = "-c" ]; then exit 0; fi\n' + 'echo "SELECTED:' + name + '"\nexit 0\n', + ) + + def real(self, name: str) -> None: + """テスト実行中の本物の Python に委譲する.""" + self._write(name, 'exec "' + REAL_PYTHON + '" "$@"\n') + + +def make_env(path_dirs: list[str], extra: dict | None = None, drop: tuple = ()) -> dict: + env = dict(os.environ) + for key in drop: + env.pop(key, None) + env["PATH"] = os.pathsep.join(path_dirs) + if extra: + env.update(extra) + return env + + +def run_wrapper(args: list[str], env: dict, stdin: bytes = b"", cwd: str | None = None): + return subprocess.run( + [BASH, WRAPPER] + args, + input=stdin, + capture_output=True, + env=env, + cwd=cwd or REPO_ROOT, + timeout=TIMEOUT, + ) + + +class TempDirMixin: + def setUp(self) -> None: # noqa: D401 + self.fake = FakeBin() + self.work = tempfile.mkdtemp(prefix="hooktest_") + + def tearDown(self) -> None: + self.fake.cleanup() + shutil.rmtree(self.work, ignore_errors=True) + + def write_script(self, name: str, source: str) -> str: + path = os.path.join(self.work, name) + with open(path, "w", encoding="utf-8", newline="\n") as f: + f.write(source) + return path + + +# --------------------------------------------------------------------------- +# run_python.sh: Python コマンドの選択順 +# --------------------------------------------------------------------------- +class TestWrapperSelection(TempDirMixin, unittest.TestCase): + def selected(self, *wrapper_args: str) -> subprocess.CompletedProcess: + return run_wrapper(list(wrapper_args) or ["script.py"], make_env([self.fake.dir])) + + def assertSelected(self, result, name: str) -> None: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.decode().strip(), "SELECTED:" + name) + + def test_prefers_python3_when_all_available(self): + for n in ("python3", "python", "py"): + self.fake.marker(n) + self.assertSelected(self.selected(), "python3") + + def test_falls_back_to_python_when_no_python3(self): + self.fake.marker("python") + self.fake.marker("py") + self.assertSelected(self.selected(), "python") + + def test_falls_back_to_py_when_only_py(self): + self.fake.marker("py") + self.assertSelected(self.selected(), "py") + + def test_skips_dummy_python3(self): + self.fake.dummy("python3") + self.fake.marker("python") + self.fake.marker("py") + self.assertSelected(self.selected(), "python") + + def test_skips_dummy_python3_and_python(self): + self.fake.dummy("python3") + self.fake.dummy("python") + self.fake.marker("py") + self.assertSelected(self.selected(), "py") + + def test_fail_closed_flag_does_not_change_selection(self): + self.fake.dummy("python3") + self.fake.marker("python") + self.assertSelected(self.selected("--fail-closed", "script.py"), "python") + + def test_dummy_is_not_used_for_actual_run(self): + """ダミーは起動確認で弾かれ,本実行には使われない.""" + self.fake.dummy("python3") + self.fake.marker("python") + result = self.selected() + self.assertSelected(result, "python") + # ダミーが呼ばれるのは起動確認(-c '')のみで,その stderr は捨てられている + self.assertNotIn(b"DUMMY_CALLED", result.stderr) + + +# --------------------------------------------------------------------------- +# run_python.sh: Python 未検出時 +# --------------------------------------------------------------------------- +class TestWrapperNotFound(TempDirMixin, unittest.TestCase): + def test_no_python_exit_1_without_flag(self): + result = run_wrapper(["script.py"], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 1) + self.assertTrue(result.stderr.strip(), "stderr に理由が出力されること") + + def test_no_python_exit_2_with_fail_closed(self): + result = run_wrapper(["--fail-closed", "script.py"], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 2) + self.assertTrue(result.stderr.strip(), "stderr に理由が出力されること") + + def test_only_dummies_exit_1_without_flag(self): + for n in ("python3", "python", "py"): + self.fake.dummy(n) + result = run_wrapper(["script.py"], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 1) + self.assertTrue(result.stderr.strip()) + + def test_only_dummies_exit_2_with_fail_closed(self): + for n in ("python3", "python", "py"): + self.fake.dummy(n) + result = run_wrapper(["--fail-closed", "script.py"], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 2) + self.assertTrue(result.stderr.strip()) + + def test_not_found_with_no_script_args(self): + """引数なしでも異常終了(set -u 的なクラッシュ)せず規定の終了コードを返す.""" + result = run_wrapper([], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 1) + result = run_wrapper(["--fail-closed"], make_env([self.fake.dir])) + self.assertEqual(result.returncode, 2) + + +# --------------------------------------------------------------------------- +# run_python.sh: 終了コード・stdin・引数の伝搬(本物の Python に委譲) +# --------------------------------------------------------------------------- +class TestWrapperPassthrough(TempDirMixin, unittest.TestCase): + def setUp(self) -> None: + super().setUp() + self.fake.dummy("python3") # Windows の Store エイリアス相当 + self.fake.real("python") + self.env = make_env([self.fake.dir]) + + def test_exit_codes_propagate(self): + script = self.write_script("exit.py", "import sys\nsys.exit(int(sys.argv[1]))\n") + for code in (0, 1, 2, 3, 42): + for flag in ([], ["--fail-closed"]): + with self.subTest(code=code, flag=flag): + result = run_wrapper(flag + [script, str(code)], self.env) + self.assertEqual(result.returncode, code, result.stderr) + + def test_args_passed_verbatim(self): + script = self.write_script( + "argv.py", "import json, sys\nprint(json.dumps(sys.argv[1:]))\n" + ) + args = ["a b", "", "--fail-closed", "x=y", "日本語", "--flag"] + for flag in ([], ["--fail-closed"]): + with self.subTest(flag=flag): + result = run_wrapper( + flag + [script] + args, + dict(self.env, PYTHONIOENCODING="utf-8"), + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout.decode("utf-8")), args) + + def test_fail_closed_flag_not_passed_to_script(self): + script = self.write_script( + "argv.py", "import json, sys\nprint(json.dumps(sys.argv[1:]))\n" + ) + result = run_wrapper(["--fail-closed", script, "one"], self.env) + self.assertEqual(json.loads(result.stdout.decode()), ["one"]) + + def test_stdin_passed_through(self): + script = self.write_script( + "stdin.py", + "import sys\nsys.stdout.buffer.write(sys.stdin.buffer.read())\n", + ) + payload = json.dumps({"tool_name": "Read", "x": "日本語"}, ensure_ascii=False).encode("utf-8") + payload = payload * 200 # ある程度の大きさでも欠けないこと + for flag in ([], ["--fail-closed"]): + with self.subTest(flag=flag): + result = run_wrapper(flag + [script], self.env, stdin=payload) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, payload) + + def test_stdin_not_consumed_by_probe(self): + """起動確認(-c '')が stdin を読み捨てないこと(ダミー・本物ともに).""" + self.fake.real("python3") # python3 も本物にして probe → 本実行の経路を通す + script = self.write_script( + "stdin.py", "import sys\nsys.stdout.write(sys.stdin.read())\n" + ) + result = run_wrapper([script], self.env, stdin=b"hello-stdin") + self.assertEqual(result.stdout, b"hello-stdin") + + def test_stdin_script_dash(self): + """check_sync.sh と同じ `-` 形式(ヒアドキュメントのスクリプト)が動く.""" + result = run_wrapper( + ["-", "arg1"], + self.env, + stdin=b"import sys\nprint('ok:' + sys.argv[1])\n", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.decode().strip(), "ok:arg1") + + +# --------------------------------------------------------------------------- +# settings.json の配線 +# --------------------------------------------------------------------------- +class TestSettingsJson(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + with open(SETTINGS, encoding="utf-8") as f: + cls.raw = f.read() + cls.settings = json.loads(cls.raw) + cls.commands = [] + for event, groups in cls.settings.get("hooks", {}).items(): + for group in groups: + for hook in group.get("hooks", []): + if hook.get("type") == "command": + cls.commands.append((event, group.get("matcher"), hook)) + + def test_restrict_repo_access_uses_wrapper_with_fail_closed(self): + cmds = [h["command"] for _, _, h in self.commands if "restrict_repo_access.py" in h["command"]] + self.assertEqual( + cmds, + ["bash .claude/hooks/run_python.sh --fail-closed .claude/hooks/restrict_repo_access.py"], + ) + + def test_restrict_repo_access_is_pretooluse_for_file_tools(self): + entries = [(e, m) for e, m, h in self.commands if "restrict_repo_access.py" in h["command"]] + self.assertEqual(len(entries), 1) + event, matcher = entries[0] + self.assertEqual(event, "PreToolUse") + for tool in ("Read", "Write", "Edit", "Glob", "Grep", "Bash"): + self.assertIn(tool, matcher.split("|")) + + def test_notify_three_hooks_use_wrapper_without_fail_closed(self): + cmds = [h["command"] for _, _, h in self.commands if "notify.py" in h["command"]] + self.assertEqual(len(cmds), 3) + for cmd in cmds: + self.assertEqual(cmd, "bash .claude/hooks/run_python.sh .claude/hooks/notify.py") + self.assertNotIn("--fail-closed", cmd) + + def test_notify_events(self): + events = sorted(e for e, _, h in self.commands if "notify.py" in h["command"]) + self.assertEqual(events, ["Notification", "PreToolUse", "Stop"]) + + def test_no_direct_python_invocation_left(self): + self.assertIsNone( + re.search(r"\b(python3?|py)\s+\.claude/hooks/", self.raw), + "python .claude/hooks/ の直接呼び出しが残っている", + ) + for _, _, hook in self.commands: + if ".py" in hook["command"]: + self.assertTrue( + hook["command"].startswith("bash .claude/hooks/run_python.sh "), + hook["command"], + ) + + def test_referenced_scripts_exist(self): + for _, _, hook in self.commands: + for token in hook["command"].split(): + if token.startswith(".claude/hooks/"): + self.assertTrue(os.path.isfile(os.path.join(REPO_ROOT, token)), token) + + +# --------------------------------------------------------------------------- +# restrict_repo_access.py をラッパー経由で(settings.json と同じコマンドで)起動 +# --------------------------------------------------------------------------- +class TestRestrictViaWrapper(TempDirMixin, unittest.TestCase): + OUTSIDE = os.path.join(os.path.dirname(REPO_ROOT), "outside_of_repo_dummy.txt") + INSIDE = os.path.join(REPO_ROOT, "CLAUDE.md") + + def run_hook(self, data: dict, env: dict) -> subprocess.CompletedProcess: + # settings.json の command と同じ相対パス・cwd で起動する + return subprocess.run( + [BASH, ".claude/hooks/run_python.sh", "--fail-closed", ".claude/hooks/restrict_repo_access.py"], + input=json.dumps(data).encode("utf-8"), + capture_output=True, + env=env, + cwd=REPO_ROOT, + timeout=TIMEOUT, + ) + + @staticmethod + def is_blocked(result) -> bool: + if result.returncode == 2: + return True + if result.returncode != 0: + return False + out = result.stdout.decode("utf-8").strip() + if not out: + return False + decision = json.loads(out).get("hookSpecificOutput", {}).get("permissionDecision") + return decision == "deny" + + def envs(self): + # 1) 実環境の PATH(このマシンでは python3 が Store エイリアスのダミー) + yield "real PATH", dict(os.environ) + # 2) ダミー python3 + 本物 python のみ + self.fake.dummy("python3") + self.fake.real("python") + yield "dummy python3 + python", make_env([self.fake.dir]) + + def test_blocks_read_outside_repo(self): + data = {"tool_name": "Read", "tool_input": {"file_path": self.OUTSIDE}, "cwd": REPO_ROOT} + for label, env in self.envs(): + with self.subTest(env=label): + result = self.run_hook(data, env) + self.assertTrue(self.is_blocked(result), (result.returncode, result.stdout, result.stderr)) + + def test_blocks_destructive_bash_outside_repo(self): + outside_dir = os.path.dirname(REPO_ROOT).replace("\\", "/") + data = {"tool_name": "Bash", "tool_input": {"command": f'rm -rf "{outside_dir}/foo"'}, "cwd": REPO_ROOT} + for label, env in self.envs(): + with self.subTest(env=label): + result = self.run_hook(data, env) + self.assertTrue(self.is_blocked(result), (result.returncode, result.stdout, result.stderr)) + + def test_allows_inside_repo(self): + cases = [ + {"tool_name": "Read", "tool_input": {"file_path": self.INSIDE}, "cwd": REPO_ROOT}, + {"tool_name": "Edit", "tool_input": {"file_path": os.path.join(REPO_ROOT, "docs", "x.md")}, "cwd": REPO_ROOT}, + {"tool_name": "Grep", "tool_input": {"pattern": "x"}, "cwd": REPO_ROOT}, + {"tool_name": "Bash", "tool_input": {"command": "rm -rf ./build"}, "cwd": REPO_ROOT}, + ] + for label, env in self.envs(): + for data in cases: + with self.subTest(env=label, tool=data["tool_name"]): + result = self.run_hook(data, env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.is_blocked(result), result.stdout) + + def test_blocks_everything_when_python_missing(self): + """Python が無いと fail-closed で exit 2(リポジトリ内でもブロック).""" + data = {"tool_name": "Read", "tool_input": {"file_path": self.INSIDE}, "cwd": REPO_ROOT} + self.fake.dummy("python3") + result = self.run_hook(data, make_env([self.fake.dir])) + self.assertEqual(result.returncode, 2) + self.assertTrue(result.stderr.strip()) + + +# --------------------------------------------------------------------------- +# notify.py をラッパー経由で起動(ntfy.sh には送らない) +# --------------------------------------------------------------------------- +class _Capture(http.server.BaseHTTPRequestHandler): + received: list = [] + + def do_POST(self): # noqa: N802 + length = int(self.headers.get("Content-Length", 0)) + _Capture.received.append(self.rfile.read(length)) + self.send_response(200) + self.end_headers() + + def log_message(self, *args): # 出力を抑制 + pass + + +class TestNotifyViaWrapper(TempDirMixin, unittest.TestCase): + def run_notify(self, data: dict, env: dict) -> subprocess.CompletedProcess: + return subprocess.run( + [BASH, ".claude/hooks/run_python.sh", ".claude/hooks/notify.py"], + input=json.dumps(data).encode("utf-8"), + capture_output=True, + env=env, + cwd=REPO_ROOT, + timeout=TIMEOUT, + ) + + def test_no_topic_does_nothing(self): + env = dict(os.environ) + env.pop("NTFY_TOPIC", None) + env["NTFY_SERVER"] = "http://127.0.0.1:9" # 万一送信しても外部に出ない + result = self.run_notify({"hook_event_name": "Stop", "cwd": REPO_ROOT}, env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, b"") + self.assertEqual(result.stderr, b"") + + def test_python_missing_is_non_blocking(self): + self.fake.dummy("python3") + env = make_env([self.fake.dir]) + env.pop("NTFY_TOPIC", None) + result = self.run_notify({"hook_event_name": "Stop"}, env) + self.assertEqual(result.returncode, 1) + self.assertNotEqual(result.returncode, 2) + + def test_sends_to_local_server_with_repo_name(self): + """ローカル HTTP サーバー宛てに送信し,Python 3.7 互換化後も内容が正しいこと.""" + _Capture.received = [] + server = http.server.HTTPServer(("127.0.0.1", 0), _Capture) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + repo = os.path.join(self.work, "folder-name") + os.makedirs(repo) + subprocess.run(["git", "init", "-q", repo], check=True, capture_output=True) + subprocess.run( + ["git", "-C", repo, "remote", "add", "origin", "git@github.com:owner/my-repo.git"], + check=True, + capture_output=True, + ) + self.fake.dummy("python3") + self.fake.real("python") + env = make_env( + [self.fake.dir] + bash_bin_dirs(), + extra={ + "NTFY_TOPIC": "test-topic", + "NTFY_SERVER": f"http://127.0.0.1:{server.server_address[1]}", + }, + ) + result = self.run_notify({"hook_event_name": "Stop", "cwd": repo}, env) + self.assertEqual(result.returncode, 0, result.stderr) + finally: + server.shutdown() + server.server_close() + self.assertEqual(len(_Capture.received), 1) + payload = json.loads(_Capture.received[0].decode("utf-8")) + self.assertEqual(payload["topic"], "test-topic") + self.assertEqual(payload["title"], "Claude Code (my-repo)") + + +# --------------------------------------------------------------------------- +# notify.project_name(removesuffix 置換後の挙動) +# --------------------------------------------------------------------------- +class TestNotifyProjectName(TempDirMixin, unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + sys.path.insert(0, HOOKS_DIR) + try: + import notify # noqa: WPS433 + finally: + sys.path.pop(0) + cls.notify = notify + + def repo_with_origin(self, url: str | None) -> str: + repo = os.path.join(self.work, "local-folder") + os.makedirs(repo, exist_ok=True) + subprocess.run(["git", "init", "-q", repo], check=True, capture_output=True) + if url is not None: + subprocess.run(["git", "-C", repo, "remote", "add", "origin", url], check=True, capture_output=True) + return repo + + def test_cases(self): + cases = [ + ("https://github.com/owner/repo.git", "repo"), + ("https://github.com/owner/repo", "repo"), + ("https://github.com/owner/repo/", "repo"), + ("git@github.com:owner/repo.git", "repo"), + ("git@github.com:owner/repo", "repo"), + ("https://github.com/owner/my.git.repo.git", "my.git.repo"), + ("https://github.com/owner/gitrepo", "gitrepo"), + ] + for url, expected in cases: + with self.subTest(url=url): + shutil.rmtree(os.path.join(self.work, "local-folder"), ignore_errors=True) + repo = self.repo_with_origin(url) + self.assertEqual(self.notify.project_name(repo), expected) + + def test_no_origin_returns_folder(self): + repo = self.repo_with_origin(None) + self.assertEqual(self.notify.project_name(repo), "local-folder") + + def test_only_dotgit_falls_back_to_folder(self): + repo = self.repo_with_origin("https://example.com/.git") + self.assertEqual(self.notify.project_name(repo), "local-folder") + + +# --------------------------------------------------------------------------- +# notify.build_notification(どのイベントで通知するか) +# --------------------------------------------------------------------------- +def _subagent_task(agent_type: str = "coder") -> dict: + return {"id": "t1", "type": "subagent", "status": "running", "description": "...", "agent_type": agent_type} + + +class TestNotifyBuildNotification(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + sys.path.insert(0, HOOKS_DIR) + try: + import notify # noqa: WPS433 + finally: + sys.path.pop(0) + cls.notify = notify + + def build(self, data: dict): + return self.notify.build_notification(data) + + # Stop: 依頼したエージェントが動いている間は通知しない + def test_stop_without_background_tasks_notifies(self): + self.assertIsNotNone(self.build({"hook_event_name": "Stop"})) + + def test_stop_with_empty_background_tasks_notifies(self): + self.assertIsNotNone(self.build({"hook_event_name": "Stop", "background_tasks": []})) + + def test_stop_with_running_subagent_is_silent(self): + self.assertIsNone(self.build({"hook_event_name": "Stop", "background_tasks": [_subagent_task()]})) + + def test_stop_with_running_workflow_is_silent(self): + task = {"id": "w1", "type": "workflow", "status": "running", "description": "...", "name": "wf"} + self.assertIsNone(self.build({"hook_event_name": "Stop", "background_tasks": [task]})) + + def test_stop_with_shell_task_still_notifies(self): + """開発サーバー等の常駐 shell / monitor で通知が止まらないこと.""" + for kind in ("shell", "monitor"): + with self.subTest(kind=kind): + task = {"id": "s1", "type": kind, "status": "running", "description": "..."} + self.assertIsNotNone(self.build({"hook_event_name": "Stop", "background_tasks": [task]})) + + def test_stop_with_mixed_tasks_is_silent(self): + tasks = [{"id": "s1", "type": "shell", "status": "running", "description": "..."}, _subagent_task()] + self.assertIsNone(self.build({"hook_event_name": "Stop", "background_tasks": tasks})) + + def test_stop_with_malformed_background_tasks_notifies(self): + """想定外の形(古いバージョン・壊れた入力)でも通知を落とさない.""" + for tasks in ("not-a-list", {"type": "subagent"}, [None, "x", 1], [{}]): + with self.subTest(tasks=tasks): + self.assertIsNotNone(self.build({"hook_event_name": "Stop", "background_tasks": tasks})) + + # 待ち状態の通知はエージェント名を添える + def test_permission_prompt_from_subagent_names_agent(self): + data = {"hook_event_name": "Notification", "notification_type": "permission_prompt", + "agent_id": "a1", "agent_type": "coder"} + self.assertIn("coder", self.build(data)["message"]) + + def test_permission_prompt_from_main_has_no_agent_label(self): + data = {"hook_event_name": "Notification", "notification_type": "permission_prompt"} + self.assertNotIn("エージェント", self.build(data)["message"]) + + def test_question_from_subagent_names_agent(self): + data = {"hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion", "agent_type": "tester"} + self.assertIn("tester", self.build(data)["message"]) + + def test_agent_name_is_trimmed_and_capped(self): + data = {"hook_event_name": "Notification", "notification_type": "permission_prompt", + "agent_type": " a" + chr(10) + "b " + "x" * 200} + message = self.build(data)["message"] + self.assertNotIn(chr(10), message) + self.assertIn("a b", message) + self.assertLess(len(message), 80) + + def test_blank_agent_type_is_ignored(self): + for value in ("", " ", None, 123): + with self.subTest(value=value): + data = {"hook_event_name": "Notification", "notification_type": "idle_prompt", "agent_type": value} + self.assertNotIn("エージェント", self.build(data)["message"]) + + def test_unknown_event_is_silent(self): + self.assertIsNone(self.build({"hook_event_name": "SubagentStop"})) + self.assertIsNone(self.build({"hook_event_name": "PreToolUse", "tool_name": "Bash"})) + + +# --------------------------------------------------------------------------- +# Python 3.7 互換(静的チェック) +# --------------------------------------------------------------------------- +PY39_ONLY_ATTRS = {"removesuffix", "removeprefix", "is_relative_to", "with_stem", "readlink", "randbytes", "lcm", "cache"} +PY38_PLUS_MODULES = {"zoneinfo", "graphlib", "tomllib", "importlib.metadata"} +GENERIC_BUILTINS = {"list", "dict", "tuple", "set", "frozenset", "type"} + + +class TestPython37Compat(unittest.TestCase): + def check_file(self, path: str) -> None: + with open(path, encoding="utf-8") as f: + source = f.read() + # 3.8+ 専用構文(walrus・位置専用引数等)を検出する + tree = ast.parse(source, filename=path, feature_version=(3, 7)) + + # from __future__ import annotations(docstring の直後) + body = tree.body + idx = 1 if body and isinstance(body[0], ast.Expr) and isinstance(getattr(body[0], "value", None), ast.Constant) else 0 + first = body[idx] + self.assertIsInstance(first, ast.ImportFrom, "先頭に from __future__ import annotations が必要") + self.assertEqual(first.module, "__future__") + self.assertIn("annotations", [a.name for a in first.names]) + + # アノテーション以外(実行時に評価される箇所)での list[...] / X | None を検出 + annotation_nodes = set() + for node in ast.walk(tree): + anns = [] + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + anns.append(node.returns) + a = node.args + for arg in a.args + a.kwonlyargs + getattr(a, "posonlyargs", []) + [a.vararg, a.kwarg]: + if arg is not None: + anns.append(arg.annotation) + elif isinstance(node, ast.AnnAssign): + anns.append(node.annotation) + for ann in anns: + if ann is not None: + annotation_nodes.update(id(n) for n in ast.walk(ann)) + + problems = [] + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and node.attr in PY39_ONLY_ATTRS: + problems.append(f"{node.lineno}: .{node.attr}") + if isinstance(node, (ast.Import, ast.ImportFrom)): + names = [node.module] if isinstance(node, ast.ImportFrom) else [a.name for a in node.names] + for name in names: + if name in PY38_PLUS_MODULES: + problems.append(f"{node.lineno}: import {name}") + if id(node) in annotation_nodes: + continue + if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name) and node.value.id in GENERIC_BUILTINS: + problems.append(f"{node.lineno}: 実行時の {node.value.id}[...]") + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + if any(isinstance(s, ast.Constant) and s.value is None for s in (node.left, node.right)): + problems.append(f"{node.lineno}: 実行時の X | None") + if hasattr(ast, "Match") and isinstance(node, ast.Match): + problems.append(f"{node.lineno}: match 文") + self.assertEqual(problems, [], f"{os.path.basename(path)} に 3.7 非互換の記述") + + def test_notify_py37_compatible(self): + self.check_file(NOTIFY) + + def test_restrict_repo_access_py37_compatible(self): + self.check_file(RESTRICT) + + +# --------------------------------------------------------------------------- +# check_sync.sh: python3 決め打ちではなくラッパー経由 +# --------------------------------------------------------------------------- +# check_sync.sh は team 層のファイルで,solo モードのプロジェクトには配置されない. +# 一方このテスト自体は共通層として全プロジェクトに同期されるため,存在ガードで skip する. +@unittest.skipUnless(os.path.exists(CHECK_SYNC), "check_sync.sh は team 層のため solo には無い") +class TestCheckSync(TempDirMixin, unittest.TestCase): + def test_no_hardcoded_python3(self): + with open(CHECK_SYNC, encoding="utf-8") as f: + lines = [l for l in f.read().splitlines() if not l.lstrip().startswith("#")] + code = "\n".join(lines) + self.assertIsNone(re.search(r"(^|[\s;|&(])(python3?|py)\s", code), "python コマンドの直接呼び出しが残っている") + self.assertIn("run_python.sh", code) + + def test_emits_json_with_dummy_python3(self): + """ダミー python3 しか無い環境(Windows 相当)でも JSON を出力する.""" + # origin が存在しないパスなので fetch が失敗し emit が呼ばれる + repo = os.path.join(self.work, "repo") + os.makedirs(repo) + subprocess.run(["git", "init", "-q", repo], check=True, capture_output=True) + subprocess.run( + ["git", "-C", repo, "remote", "add", "origin", os.path.join(self.work, "no-such-remote")], + check=True, + capture_output=True, + ) + self.fake.dummy("python3") + self.fake.real("python") + env = make_env([self.fake.dir] + bash_bin_dirs(), drop=("PYTHONIOENCODING",)) + result = subprocess.run( + [BASH, CHECK_SYNC], + capture_output=True, + env=env, + cwd=repo, + timeout=TIMEOUT, + ) + self.assertEqual(result.returncode, 0, result.stderr) + out = json.loads(result.stdout.decode("utf-8")) + self.assertIn("[sync-check]", out["systemMessage"]) + self.assertEqual(out["hookSpecificOutput"]["hookEventName"], "SessionStart") + self.assertEqual(out["hookSpecificOutput"]["additionalContext"], out["systemMessage"]) + + def test_python_missing_does_not_block_session(self): + repo = os.path.join(self.work, "repo") + os.makedirs(repo) + subprocess.run(["git", "init", "-q", repo], check=True, capture_output=True) + subprocess.run( + ["git", "-C", repo, "remote", "add", "origin", os.path.join(self.work, "no-such-remote")], + check=True, + capture_output=True, + ) + self.fake.dummy("python3") + env = make_env([self.fake.dir] + bash_bin_dirs()) + # bash_bin_dirs に python が無い前提(あればこのテストは意味をなさないのでスキップ) + for d in bash_bin_dirs(): + for n in ("python3", "python", "py"): + if os.path.exists(os.path.join(d, n)) or os.path.exists(os.path.join(d, n + ".exe")): + self.skipTest(f"{d} に {n} がある") + result = subprocess.run([BASH, CHECK_SYNC], capture_output=True, env=env, cwd=repo, timeout=TIMEOUT) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/rules/template-customization.md b/.claude/rules/template-customization.md index 8e62ef2..58f1c59 100644 --- a/.claude/rules/template-customization.md +++ b/.claude/rules/template-customization.md @@ -12,6 +12,7 @@ - **対象(テンプレートが管理するファイル)**: `.claude/agents/`,`.claude/skills/`(テンプレート提供の skill),`.claude/rules/`(`git-conventions`・`markdown-style`・`docs-naming`・`progress-log`・本ルール等,テンプレート由来のもの),`.claude/hooks/`,`docs/01_GUIDE/`(`GUIDE_01`〜`GUIDE_03`) - **対象外**: - プロジェクトが新規に作ったファイル(立ち上げ時に作成するコーディング規約 rule,プロジェクト独自の skill / agent 等).テンプレートに存在しないので同期で上書きされない + - `.claude/verify-profile.md`(検証プロファイル).テンプレートが持つのは雛形(`.claude/skills/verify/profile-template.md`)だけであり,プロファイル本体はプロジェクト所有のため同期で触られない.台帳登録は不要 - `CLAUDE.md`・`.gitignore`・`docs/PROGRESS.md`・`.gitattributes`・`.claude/settings.json`.これらは `/sync-template` の「マージ必須ファイル」として別途保護されており,台帳登録は不要 - テンプレート由来かどうか判断に迷う場合は**記録する側に倒す**.余計な記録は `/sync-template` が「テンプレート側に存在しないパス」として警告してくれるが,記録漏れは改変の消失につながる diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 7134d93..6d759f6 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -18,6 +18,27 @@ 標準に則る.NUnit の慣例(AAA: Arrange-Act-Assert)に従い,テストは実装のなぞり書きではなく**仕様(期待される入出力)を基準**に書く. +## アサーションの書き方 (Assertion Style) + +**制約モデル(`Assert.That` + `Is.*` / `Does.*`)のみを使う**.Classic assertion(`Assert.AreEqual`・`Assert.IsTrue`・`StringAssert.Contains` 等)は新規に書かないこと. + +理由: NUnit 4 で Classic assertion は `Assert` から削除され `NUnit.Framework.Legacy.ClassicAssert` へ移動した.一方で制約モデルは NUnit 3 でも 4 でも同じく動くため,制約モデルに揃えておけば NUnit のメジャー更新にテストコードの変更なしで追従できる(既存の 209 箇所は 2026-08-28 に移行済み). + +| 用途 | 書き方 | +| --- | --- | +| 等値 | `Assert.That(actual, Is.EqualTo(expected))` | +| 浮動小数点の等値 | `Assert.That(actual, Is.EqualTo(expected).Within(delta))` | +| 真偽 | `Assert.That(x, Is.True)` / `Assert.That(x, Is.False)` | +| null | `Assert.That(x, Is.Null)` / `Assert.That(x, Is.Not.Null)` | +| 同一性 | `Assert.That(actual, Is.SameAs(expected))` | +| 部分文字列 | `Assert.That(actual, Does.Contain(expected))` | +| 例外 | `Assert.Throws((Action)(() => ...))` / `Assert.DoesNotThrow((Action)(() => ...))` | + +- **例外系のラムダは `(Action)` にキャストする**.NUnit 4.6 は `TestDelegate` 版(`[Obsolete]`)と `Action` 版(`[OverloadResolutionPriority]`)の両方を持ち,LangVersion 7.3 のテストプロジェクトでは優先度属性が解釈されず CS0121(呼び出しが曖昧)になる.LangVersion は古い VS / Build Tools との互換のため上げない(2026-09-29 の NUnit 4.6.1 移行で 16 箇所を対応). + +- **引数の順序に注意する**.Classic は `(expected, actual)` だが制約モデルは `(actual, 制約)` である.とくに `Does.Contain` は第 1 引数が**検査対象**で,期待する部分文字列は制約の側に入る(逆にすると意味が反転したまま通ってしまう場合がある). +- 浮動小数点の比較で `.Within(...)` を落とさないこと.厳密比較になりフレーキーになる. + ## テストの種類 (Test Types) | 種類 | 対象 | 自動化 | 実行タイミング | @@ -25,6 +46,7 @@ | 単体テスト | ピュアロジック層 | 自動(NUnit) | コミット前・`/implement` Phase 2 | | ビルドテスト | ソリューション全体 | 自動(MSBuild) | 全コミット前 | | 回帰テスト | 色補正・チャート検出の出力 | 半自動(基準データ比較) | リファクタ・修正の前後 | +| セルフテスト | 依存ロード・偽カメラでのプレビュー経路・合成チャートでの校正〜保存 | 自動(`--selftest`.`/verify`・`/implement` Phase 1b) | 依存更新・ハードウェア結合層の変更後,手動テストの前段 | | 手動テスト | 起動〜撮影〜保存の一連動作 | 手動(実機) | 機能追加・修正の完了時 | 回帰テスト・手動テストの具体手順は [TEST_01_テスト仕様](../../docs/06_TEST/TEST_01_テスト仕様.md) に記載する. @@ -52,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) @@ -79,10 +101,7 @@ テストプロジェクトは本体に合わせたレガシー形式(packages.config)のため `dotnet test` は使えない.ビルド後に `vstest.console.exe` を **x64** で実行する(ネイティブ DLL を x64 でロードするため). -```powershell -& "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe" TIASshot.sln /t:Build /p:Configuration=Debug /p:Platform=x64 -& "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe" "TIASshot.Tests\bin\x64\Debug\TIASshot.Tests.dll" /Platform:x64 -``` +**具体的なコマンドは [ENV_04_開発コマンド](../../docs/02_ENV/ENV_04_開発コマンド.md) を参照する**(本 rule は方針,ENV_04 はコマンドという役割分担).MSBuild・`vstest.console.exe` の場所は VS のエディション(Community / Professional / Enterprise / Build Tools)によって変わるため,パスを直書きせず ENV_04 の `vswhere` による解決手順を使うこと. Visual Studio 上ではテストエクスプローラーから実行できる(プラットフォームを x64 にすること). diff --git a/.claude/settings.json b/.claude/settings.json index b06d6ff..9c8a859 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -11,7 +11,40 @@ "hooks": [ { "type": "command", - "command": "python .claude/hooks/restrict_repo_access.py" + "command": "bash .claude/hooks/run_python.sh --fail-closed .claude/hooks/restrict_repo_access.py" + } + ] + }, + { + "matcher": "AskUserQuestion", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/run_python.sh .claude/hooks/notify.py", + "async": true + } + ] + } + ], + "Notification": [ + { + "matcher": "permission_prompt|elicitation_dialog|elicitation_url_dialog", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/run_python.sh .claude/hooks/notify.py", + "async": true + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/run_python.sh .claude/hooks/notify.py", + "async": true } ] } diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md index ffc6932..d242253 100644 --- a/.claude/skills/implement/SKILL.md +++ b/.claude/skills/implement/SKILL.md @@ -1,7 +1,7 @@ --- name: implement model: inherit -description: "実装パイプラインを開始する.コーディング → テスト → リファクタリングの順で 3 つのエージェントを実行し,必要に応じて人間の確認を挟む." +description: "実装パイプラインを開始する.コーディング(+任意の検証)→ テスト → リファクタリングの順で専門エージェントを実行し,必要に応じて人間の確認を挟む." argument-hint: "<タスク内容>" --- @@ -21,18 +21,75 @@ 「以下のタスクを実装してください: $ARGUMENTS」 -Coder の出力(実装サマリー)を記録し,ユーザーに提示する: +Coder の出力(実装サマリー)を記録し,Phase 1b に進む. + +## Phase 1b: 検証 (Verifier) + +**検証プロファイル(`.claude/verify-profile.md`)を用意したプロジェクトでのみ動く任意のフェーズ**.人間の動作確認の**前段**として,verifier エージェントに動作とエッジケースを確認させ,人間が見る範囲を絞る(人間の確認を置き換えるものではない.CLAUDE.md「手動確認が必要な作業」). +検証する環境はローカル環境と,検証プロファイルの「検証対象の環境」が明示的に許可した検証用環境に限られる(本番は対象外). + +### 実行判定 + +以下のいずれかに該当する場合は verifier を起動せず,**理由を 1 行控えて**「人間への提示」に進む(エラーにしない): + +- `.claude/verify-profile.md` が無い(検証プロファイル未設定.この場合が既定であり,従来どおり人間が動作確認する) +- 変更が,観測可能な挙動に関係しない(ドキュメント・設定・テスト・型定義のみ等) +- 検証の前提を整えるのに破壊的な操作(データの初期化・マイグレーションの適用等)が必要で,未実施である(無断で実行せず,人間に依頼する) +- ユーザーがタスク内容で検証の省略を指示している + +### 委譲 + +verifier エージェントに以下を委譲する(検証手段・実行コマンドは書かない.verifier が検証プロファイルを読む): + +「以下の実装について,動作とエッジケースを確認してください(検証する環境は検証プロファイルの『検証対象の環境』に従う). + +タスク: $ARGUMENTS + +実装サマリー: +{Coder の出力}」 + +### 結果による分岐 + +- **OK**: 「人間への提示」に進む +- **NG(今回の変更に起因する不具合)**: 人間に回す前に,coder エージェントへ verifier の不具合報告(再現手順・期待・実際・推定原因)を渡して修正を委譲し,verifier に再確認させる.この自動差し戻しは **2 回まで**.それでも NG なら,未解決の不具合を添えて「人間への提示」に進み,方針を仰ぐ +- **NG(今回の変更と無関係な既存の問題のみ)**: 差し戻さない.「気になった点」と合わせて別件として人間に伝え,`/task-create` での起票を案内する +- **検証不能**(前提条件を満たさない等): 理由を控えて「人間への提示」に進む + +差し戻しで coder が修正した場合は,実装サマリーを最新の内容に更新して以降のフェーズに渡す. + +### 人間への提示 + +**verifier を起動しなかった場合**(既定)は,理由を 1 行添えて従来どおり依頼する: 「**Phase 1(コーディング)が完了しました.** {Coder の実装サマリー} +※ Claude による自動検証は行っていません(理由: {1 行.例: 検証プロファイル未設定}) + ブラウザや実機で動作を確認してください. - **OK** → 次のフェーズ(テスト)に進みます - **NG** → 問題点を教えてください.修正します」 -ユーザーの OK を得るまで Phase 2 に進まないこと. -NG の場合は coder エージェントに修正を委譲し,再度確認を依頼する. +**verifier を起動した場合**は,「確認済み」と「確認してほしい」の 2 部構成で提示する: + +「**Phase 1(コーディング)が完了しました.** + +{Coder の実装サマリー} + +**Claude が確認済みの項目** +{verifier の「確認した項目」の表.差し戻しで直した不具合があればその内容も.検証不能だった場合は,この節を「検証を実行できませんでした(理由: …)」に置き換える} + +**あなたに確認してほしい項目** +{verifier の「人間が確認すべき残り項目」.省略・要約しすぎない.検証不能だった場合は「ブラウザや実機で動作を確認してください」} + +{未解決の不具合・今回の変更と無関係な既存の問題があれば,ここに分けて記載する} + +- **OK** → 次のフェーズ(テスト)に進みます +- **NG** → 問題点を教えてください.修正します」 + +ユーザーの OK を得るまで Phase 2 に進まないこと.**verifier の判定が OK でも,それをユーザーの OK の代わりにしてはならない.** +NG の場合は coder エージェントに修正を委譲し,再度確認を依頼する(修正が観測可能な挙動に関わり,検証プロファイルがある場合は Phase 1b からやり直す). ## Phase 2: テスト (Tester) diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 19f0839..59b16ee 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -17,6 +17,15 @@ - 成果物のドラフトを提示し,ユーザーの修正指示を反映してからファイルに書き出す - フォルダは成果物を実際に書き出すときにのみ作成する.空のカテゴリフォルダを先回りで作らない +## 立ち上げ中の Git 運用 (Git During Setup) + +立ち上げ作業は専用ブランチ `chore/project-setup` の上で行い,**各フェーズの成果物を書き出してユーザーが承認したら,その場でコミットする**(フェーズごとに 1 コミット).手順・コミットメッセージの対応表は本スキルの `reference.md`(`.claude/skills/setup/reference.md`)「フェーズごとのコミット」に従う. + +- 本スキルは CLAUDE.md「Git 運用」の「`/commit` 自発実行禁止」に対する**ユーザー承認済みの例外**である(`/auto-refactor`・`/auto-audit` と同格).例外が認められるのは `chore/project-setup` ブランチへのコミットに限る +- **`git push`・PR 作成・マージ・`main` への直接コミットは一切行わない**.`main` への取り込みは全フェーズ完了後にユーザーが `/commit push`(または `/commit merge`)で行う +- ブランチは最初のコミットを作る直前に `main` から作成する.既に `chore/project-setup` にいる場合(再開時)はそのまま使い続ける +- コミット履歴がそのままフェーズの進捗になるため,中断・再開しても「どこまで終わったか」が git から辿れる(`CLAUDE.md` の進捗欄・`docs/PROGRESS.md` の更新はそのフェーズのコミットに含める) + ## 前提確認 (Pre-check) 1. CLAUDE.md がテンプレート初期状態(タイトルが `# プロジェクト名` のまま)か確認する @@ -77,6 +86,7 @@ 2. ユーザーとの対話で情報を収集する 3. 成果物のドラフトを作成し,ユーザーに提示する 4. 承認を得てから GUIDE_01 に記載された保存先に書き出す +5. 書き出した成果物をコミットする(前述「立ち上げ中の Git 運用」・`reference.md`「フェーズごとのコミット」.push・PR は行わない) ### 台詞テンプレート @@ -84,7 +94,7 @@ {ドラフト内容} -修正点があれば指示してください.OKであれば {保存先} に保存して次のフェーズに進みます.」 +修正点があれば指示してください.OKであれば {保存先} に保存し,`chore/project-setup` にコミットして次のフェーズに進みます.」 ### フォルダ作成の注意 @@ -94,6 +104,7 @@ 外部サービスの設定(アカウント作成,コンソール操作等)はユーザー自身が行う.手順書に記載するが,AI が実行しないこと. **例外**として,`gh` CLI で操作できる GitHub リポジトリ設定は AI が実行する. +手順書を書き出した後は,それ以外の手順を実際に実行して環境を構築する(後述「環境構築の実行」.ツール導入は都度承認を得る). ### GitHub リポジトリのセキュリティ設定 (GitHub Repository Security Settings) @@ -110,6 +121,39 @@ - 生成内容(対象エコシステム・更新頻度・グループ化)は他の成果物と同様にドラフトとしてユーザーに提示し,承認を得てから書き出す(「作るかどうか」は聞かず,「内容」だけを確認する) - マニフェストがまだ存在しない場合(立ち上げ時点でコードが無い)でも,スタックが確定していれば生成してよい.Dependabot はマニフェストが置かれた時点から動き始める +### 環境構築の実行 (Executing the Environment Setup) + +`ENV_02_環境構築手順.md`・`ENV_03_管理者用環境構築手順.md` を書き出したら,**手順書を渡して終わりにせず,その手順に従って実際にこの環境を構築する**.未検証の手順書は誤り・前提の抜けを含みやすく,最初に詰まるのはその場のユーザーであるため.実行の分類(そのまま実行する/承認を取る/ユーザーに依頼する)と進め方は `reference.md`「環境構築の実行」に従う. + +- リポジトリ内で完結する操作(プロジェクト初期化・依存インストール・設定ファイル生成・ビルドや起動の疎通確認・`gh` でのリポジトリ設定)はそのまま実行する +- ツール導入(SDK・ランタイム・CLI のインストール,`docker build`,dev container の起動)は**実行するコマンドを提示して承認を得てから**実行する.OS・パッケージマネージャ・バージョン・入る場所(システム全体かプロジェクト内か)を明示する +- 外部サービスのコンソール操作・アカウント作成・対話ログイン(`gh auth login` 等)・クレデンシャルの発行はユーザーに依頼する(`! gh auth login` のように `!` を付けてその場で実行できることを案内する) +- 実行して分かった手順書の誤り・不足・OS 依存の差異は `ENV_02`/`ENV_03` に反映してからコミットする.解決できなかった手順は「※ 未検証」と明記して残す +- 確定した起動・テスト・lint・ビルドのコマンドは `ENV_04_開発コマンド.md` に書き出す(GUIDE_01「環境構築」) +- 生成物(`node_modules/`・ビルド出力等)を作る前に `.gitignore` を整備する + +### 検証プロファイルの作成(任意) (Verify Profile) + +技術スタックと開発コマンドが固まった後(フェーズ 3 の最後)に,Claude による動作確認を使うかをユーザーに 1 度だけ確認する.**既定は「作らない」**であり,作らなくても `/implement` の流れは従来どおり(Phase 1b はスキップされ,動作確認は人間が行う). + +「**Claude がローカルでアプリを操作して動作確認する仕組みを用意しますか?(任意)** + +検証プロファイル(`.claude/verify-profile.md`)を用意すると,`/implement` の Phase 1b と `/verify` で Claude がエッジケース等を先に確認し,あなたが見る範囲を「デザイン・操作感・実機の挙動」等に絞れます(人間の確認は無くなりません). +別途,シナリオを実行するランナー(起動・実行・集計・後始末)をプロジェクト側に用意する必要があります. + +- **今は作らない**(既定)— 後から `/verify` の案内に従って用意できます +- **作る** — 雛形を埋めながら一緒に作ります」 + +「作る」を選んだ場合: + +1. `.claude/skills/verify/profile-template.md` を `.claude/verify-profile.md` にコピーする +2. 雛形の各節を,確定した技術スタック・開発コマンドに合わせてドラフトし,他の成果物と同様にユーザーの承認を得てから書き出す.埋められない項目(ランナー未整備のため実行コマンドが未定等)は空欄にせず「未定(ランナー整備後に記入)」と明記する + - 「検証対象の環境」は**ローカルのみ**でドラフトする.staging 等を許可するかは Claude が判断せずユーザーに確認し,許可する場合だけ「いじっても影響が無い」と判断した根拠・資格情報の受け渡し方法・テストデータの後始末をユーザーから聞いて記入する(本番は許可しない) +3. 検証結果の出力先を `.gitignore` に追記する +4. ランナーは雛形の「実行基盤の要件」を満たすようプロジェクト側で実装する必要がある旨を伝える(この場では実装しない.必要なら立ち上げ後に `/implement` で作る) + +「今は作らない」を選んだ場合は何も作らず,後から用意できることだけ伝えて次に進む. + ## フェーズ 7: 実装開始 1. GUIDE_01 の「CLAUDE.md の管理」セクションに従い,CLAUDE.md を最終更新する @@ -122,7 +166,8 @@ 2. 作成した全ドキュメントの一覧を表示する 3. 最初の実装ステップを確認する 4. **モードに関わらず**,「GitHub リポジトリのセキュリティ設定」(前述)を実行する(Phase 3 で有効化済みなら検証コマンドだけで確認する.リモートがまだ無ければ,リポジトリ作成後に ENV_03 のコマンドを実行するようユーザーに案内する) -5. **team モードの場合**は,AI に実施できない「管理者の初期設定」(GUIDE_03「管理者の初期設定」)をユーザーに案内する: +5. CLAUDE.md の最終更新・進捗の更新を `chore/project-setup` にコミットする(前述「立ち上げ中の Git 運用」).`main` への取り込みは行わず,ユーザーに `/commit push`(または `/commit merge`)を案内する +6. **team モードの場合**は,AI に実施できない「管理者の初期設定」(GUIDE_03「管理者の初期設定」)をユーザーに案内する: - GitHub リポジトリ作成・メンバー招待 - Issue テンプレートの用意 - CI 構築(未構築の間は GUIDE_03「CI 構築までの暫定ゲート」を適用) @@ -138,6 +183,8 @@ GitHub リポジトリのセキュリティ設定(Dependabot alerts / security updates): {有効化済み / リポジトリ未作成のため未実施(ENV_03 のコマンドで有効化してください)} 依存バージョン更新(`.github/dependabot.yml`): {対象エコシステム一覧} を {更新頻度} で監視 Dependabot が作る PR・alert は `/deps-update` で処理できます(安全な更新は自動マージ,メジャー更新等は分析付きで報告). +立ち上げの成果物は `chore/project-setup` に {N} コミットで積んであります.`main` への取り込みは `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で行ってください. +環境構築: {実行した手順の要点 / ユーザーに依頼した手順 / 未検証のまま残した手順} 最初の実装ステップは `{ステップ名}` です.`/implement {タスク}` で開始できます. {team の場合: 「チーム運用の管理者初期設定(GitHub repo・CI 等)は GUIDE_03 を参照してユーザー側で実施してください.」}」 @@ -146,9 +193,11 @@ ユーザーが中断を希望した場合: 1. 現在のフェーズと状態を CLAUDE.md の「開発進捗」(最新 1 行に上書き)と `docs/PROGRESS.md`(末尾に追記)に記録する -2. 次回 `/setup` 実行時に続きから再開できるようにする +2. ここまでの成果物と進捗の更新を `chore/project-setup` にコミットする(未コミットの差分を残さない.push・PR は行わない) +3. 次回 `/setup` 実行時に続きから再開できるようにする 「**進捗を保存しました.** 現在の状態: フェーズ {N}({フェーズ名})まで完了 -次回 `/setup` を実行すると,フェーズ {N+1} から再開します.」 +`chore/project-setup` にコミット済みです(未コミットの変更はありません). +次回 `/setup` を実行すると,同じブランチでフェーズ {N+1} から再開します.」 diff --git a/.claude/skills/setup/reference.md b/.claude/skills/setup/reference.md index e1f9466..116934f 100644 --- a/.claude/skills/setup/reference.md +++ b/.claude/skills/setup/reference.md @@ -1,6 +1,6 @@ # setup リファレンス (Setup Reference) -`/setup`(`SKILL.md`)から参照される,GitHub リポジトリまわりの設定手順集(`gh` CLI で行うリポジトリ設定と,`.github/dependabot.yml` の生成規則).`/setup` の途中で AI が実行するほか,`/setup` 完了後にリポジトリを作成した場合などに管理者(人間)が単体で実行する runbook としても使う. +`/setup`(`SKILL.md`)から参照される実行手順集(`gh` CLI で行うリポジトリ設定,`.github/dependabot.yml` の生成規則,フェーズごとのコミット,環境構築の実行).`/setup` の途中で AI が実行するほか,`/setup` 完了後にリポジトリを作成した場合などに管理者(人間)が単体で実行する runbook としても使う. ## GitHub リポジトリのセキュリティ設定 (GitHub Repository Security Settings) @@ -121,3 +121,77 @@ ### 動作確認 (Verification) `main` に push された後,GitHub の Insights → Dependency graph → Dependabot で各エコシステムの「Last checked」と結果を確認できる.YAML の構文エラーはここにも表示される. + +## フェーズごとのコミット (Per-phase Commit) + +`/setup` は立ち上げ専用ブランチ `chore/project-setup` の上で,各フェーズの成果物を書き出してユーザーが承認したタイミングごとにコミットする.`git push`・PR 作成・マージ・`main` への直接コミットは行わない(CLAUDE.md「Git 運用」の例外が認められるのはこのブランチへのコミットに限る). + +### 手順 (Procedure) + +1. 最初のコミットの直前にブランチを用意する(2 回目以降のフェーズは作成済みのブランチをそのまま使う): + + ```bash + git branch --show-current # chore/project-setup なら,そのまま使う + git switch -c chore/project-setup # main にいる場合のみ作成する + ``` + + - コミットが 1 つも無い(`git log` が失敗する)場合は,先に最初のコミットを作ってからブランチを切る(コミットが無いとブランチを作れないため) +2. そのフェーズで書き出したファイルだけをステージングする(`.env` やクレデンシャル・生成物はステージングしない.`.gitignore` の整備を先に済ませる) +3. `.claude/rules/git-conventions.md` の書式(`[タグ] 内容`・日本語・末尾の句点なし)でコミットする + + ```bash + git add docs/03_PLAN/PLAN_01_要件定義書.md + git commit -m "[add] 要件定義書を作成" + ``` + +4. コミットした旨(ブランチ・メッセージ)を次フェーズの案内と一緒に 1 行で報告する + +### フェーズとコミットの対応 (Commit per Phase) + +| フェーズ | 主な対象 | コミットメッセージ例 | +| --- | --- | --- | +| 前提確認・モード選択 | `CLAUDE.md`(プロジェクト名・概要),`.claude/project-mode`,(team の場合)`settings.json`・`.gitignore` | `[update] プロジェクト名と開発モードを設定` | +| 1 方針決定 | `docs/03_PLAN/PLAN_01_要件定義書.md` | `[add] 要件定義書を作成` | +| 2 技術選定 | `docs/02_ENV/ENV_01_技術スタック.md` | `[add] 技術スタックを選定` | +| 3 環境構築 | `ENV_02`・`ENV_03`・`ENV_04`,`.gitignore`,`.github/dependabot.yml`,構築用ファイル(Dockerfile・devcontainer.json 等),(任意)`.claude/verify-profile.md` | `[add] 環境構築手順と設定ファイルを整備` | +| 4 仕様設計 | `docs/04_SPEC/SPEC_01_*.md` | `[add] 仕様設計書を作成` | +| 5 規約整備 | `.claude/rules/*.md`(コーディング規約等) | `[add] コーディング規約を整備` | +| 6 開発計画 | `docs/03_PLAN/PLAN_02_開発ステップ.md` | `[add] 開発ステップを策定` | +| 7 実装開始 | `CLAUDE.md`(最終更新),`docs/PROGRESS.md` | `[update] CLAUDE.md を立ち上げ完了状態に更新` | + +- フェーズ 3 は「手順書・設定ファイルの作成」と「実際の構築(+手順書の修正)」で差分の性質が違うため,2 コミット以上に分けてよい(例: `[add] 環境構築手順と設定ファイルを整備` → `[update] 実際の構築結果を環境構築手順に反映`) +- 進捗(`CLAUDE.md` の進捗欄・`docs/PROGRESS.md`)を更新した場合は,そのフェーズのコミットに含める +- 承認を得た成果物がまだ無いフェーズ(対話だけで終わった)ではコミットしない(空コミットを作らない) + +### 取り込み (Merging into main) + +全フェーズ完了後,ユーザーが `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で `main` に取り込む.`/setup` からは案内のみを行う. + +## 環境構築の実行 (Executing the Environment Setup) + +`/setup` のフェーズ 3 で `ENV_02_環境構築手順.md`・`ENV_03_管理者用環境構築手順.md` を書き出した後,その手順に従って実際に開発環境を構築する.手順書だけで終わらせないのは,未検証の手順書は誤りや前提の抜けを含みやすく,最初に詰まるのがその場のユーザーになるため. + +### 実行の分類 (What to Execute) + +| 区分 | 例 | 扱い | +| --- | --- | --- | +| リポジトリ内で完結する操作 | プロジェクトの初期化(`npm create`・`flutter create`・`cargo new` 等),依存インストール,設定ファイル生成,`.gitignore` 整備,ビルド・起動・テストの疎通確認,`gh` で行うリポジトリ設定(本書「GitHub リポジトリのセキュリティ設定」) | そのまま実行する | +| ツール導入(システムに入るもの) | SDK・ランタイム・CLI・パッケージマネージャのインストール,グローバル設定の変更,`docker build`,dev container の起動 | **実行するコマンドを提示して承認を得てから**実行する | +| ユーザーにしかできない操作 | 外部サービスのアカウント作成・コンソール操作・セキュリティルール変更,対話ログイン(`gh auth login`・`firebase login` 等),クレデンシャルの発行と貼り付け,実機・ブラウザでの最終確認 | 実行せずユーザーに依頼する(CLAUDE.md「手動確認が必要な作業」) | + +### 進め方 (Procedure) + +1. `ENV_02`(メンバー向け手順)を上から順に実行する.各手順の実行前に,上表のどの区分かを判定する +2. ツール導入は 1 つずつ,**実行するコマンド・OS とパッケージマネージャ・バージョン・入る場所(システム全体かプロジェクト内か)**を示して承認を得る.既に入っているものはバージョン確認(`node -v` 等)だけで済ませ,入れ直さない +3. ユーザーにしかできない操作に来たら,手順の番号と依頼内容を伝えて待つ.対話ログイン等は `! gh auth login` のように `!` を付けてその場で実行できることを案内する +4. `ENV_03`(管理者用手順)のうち `gh` で実行できるもの(リポジトリのセキュリティ設定)は実行する.リモート未作成なら Phase 7 で再試行する +5. 実行結果を記録し,**手順書を実行結果に合わせて直す**(誤ったコマンド・抜けている前提・OS 依存の差異・必要なバージョン).解決できなかった手順は該当箇所に「※ 未検証({エラーの要点})」と明記する +6. 確定した起動・テスト・lint・ビルドのコマンドを `ENV_04_開発コマンド.md` に書き出す(無ければ作成する) +7. 構築結果をユーザーに報告する(実行した手順/承認を得て導入したツール/ユーザーに依頼した手順/未検証のまま残した手順) + +### 注意 (Notes) + +- 生成物(`node_modules/`・ビルド出力・`.venv` 等)を作る前に `.gitignore` を整備する.先にインストールを走らせると `git add` が汚れる +- 失敗したコマンドを推測で変形して繰り返さない.2 回失敗したら手を止め,エラー全文と試したことを添えてユーザーに判断を仰ぐ +- 既存ファイルを上書きする初期化コマンドやグローバル設定の書き換えは,対象を確認したうえで承認を得る(空ではないディレクトリでの `create` 系は特に注意する) +- 環境構築が済んだら,疎通確認で使ったコマンド(起動・テスト)が `ENV_04` の記載と一致していることを確認する.一致しない場合は実行できたコマンドを正とする diff --git a/.claude/skills/sync-template/SKILL.md b/.claude/skills/sync-template/SKILL.md index 55d1311..d2a3d0d 100644 --- a/.claude/skills/sync-template/SKILL.md +++ b/.claude/skills/sync-template/SKILL.md @@ -61,8 +61,9 @@ | ファイル | 理由 | 方針 | | --- | --- | --- | | `README.md` | テンプレートの README は GitHub の repo ページ向けのテンプレート紹介用.各プロジェクトは独自の README を持つべき | プロジェクト側にコピー・上書きしない.テンプレート側の追加・変更・削除も無視する | +| `.claude/tests/`(配下すべて) | テンプレート自身のメタテスト(skill・エージェント定義・README 等がテンプレートとして整合しているかを検証する).テンプレート固有の内容を前提とするため派生プロジェクトでは必ず失敗する | 同上 | -判定はステップ 5.3 のループ内でマージ必須ファイル判定より先に行う. +判定はステップ 5.3 のループ内でマージ必須ファイル判定より先に行う.`SKIP_FILES` の末尾が `/` の項目はディレクトリ指定で,配下のすべてのパスに前方一致で適用する(`reference.md`「変数・判定ヘルパー」). ## モード依存ファイル (Mode-gated / Team-layer Files) @@ -75,6 +76,8 @@ ※ `task-create`・`task-start`(+ `reference.md`)・`task-handoff` の各 skill は**共通層**(solo でも Issue ベースのタスク管理に使う)であり,モードに関わらず通常どおり同期する. +※ **共通層のファイルはチーム層ファイルの存在を前提にしてはならない**.前提にする場合は存在ガードを付け,solo では失敗ではなく skip・無効化になるようにする(例: `.claude/hooks/tests/test_hooks.py` の `TestCheckSync` は `check_sync.sh` が無ければ skip する).この不変条件はメタテスト `.claude/tests/test_mode_layers.py` が検査する. + 判定はプロジェクトの `.claude/project-mode`(`solo` または `team`.`/setup` が作成)で行う: - **`team`**: チーム層ファイルを通常どおり同期(A/M/D すべて反映). @@ -326,5 +329,5 @@ - 台帳への登録は必ず理由を添える(ユーザーから聞く).理由の無い登録は次回同期時の判断材料にならない.台帳の書式(パス列はバッククォート囲み,方針列は `keep` / `merge` / `ask`)を崩さない - 同期対象外ファイル(`README.md`)はテンプレート紹介用のためプロジェクトには反映しない.テンプレート側で追加・変更・削除があってもプロジェクトの該当ファイルは触らない - チーム層ファイル(`GUIDE_03`/`check_sync.sh`)は `.claude/project-mode` が `team` のプロジェクトにのみ同期する.`task-*` skill は共通層のためモードに関わらず同期する.`solo`(または未設定)のプロジェクトには配置・更新・削除いずれもしない.`/sync-template` は「版の追従」のみを行い,**モードの切り替えはしない**.solo↔team の切替は `/set-mode ` を使う(team 層ファイルの配置/削除・`settings.json` 配線・`CLAUDE.md` の team 化/solo 化・`project-mode` 更新を一括で行う).`.claude/project-mode` を手で書き換えるだけでは切り替わらない -- テンプレートが管理するのは `.claude/` 配下のうち `agents/`,`skills/`,`rules/`,`hooks/`,`settings.json`,`template-sync-sha`,`template-overrides.md`(雛形のみ.登録内容はプロジェクト固有)のみ.`.claude/plans/` や `.claude/commit-context.md` 等のプロジェクト固有ファイルはテンプレートに含まれないため同期対象外 +- テンプレートが管理するのは `.claude/` 配下のうち `agents/`,`skills/`,`rules/`,`hooks/`,`settings.json`,`template-sync-sha`,`template-overrides.md`(雛形のみ.登録内容はプロジェクト固有)のみ.`.claude/plans/`・`.claude/commit-context.md`・`.claude/verify-profile.md`(検証プロファイル.テンプレートが持つのは雛形 `.claude/skills/verify/profile-template.md` のみ)等のプロジェクト固有ファイルはテンプレートに含まれないため同期対象外(`$CHANGED_ENTRIES` に現れず,コピー・上書き・削除のいずれも起きない) - `chore/sync-template` ブランチは他の作業ブランチと混ぜず,作成後は速やかにマージすること.複数の作業ブランチで `/sync-template` を実行すると `.claude/template-sync-sha` がコンフリクトする.コンフリクト時は新しい(HEAD 側の)SHA を採用すること. diff --git a/.claude/skills/sync-template/reference.md b/.claude/skills/sync-template/reference.md index 355b38c..0ee45b4 100644 --- a/.claude/skills/sync-template/reference.md +++ b/.claude/skills/sync-template/reference.md @@ -15,7 +15,7 @@ ```bash MERGE_FILES=(".gitignore" "CLAUDE.md" "docs/PROGRESS.md" ".gitattributes" ".claude/settings.json" ".claude/template-overrides.md") -SKIP_FILES=("README.md") +SKIP_FILES=("README.md" ".claude/tests/") # 末尾 / はディレクトリ配下すべてを対象にする TEAM_LAYER_FILES=( "docs/01_GUIDE/GUIDE_03_チーム開発ルール.md" ".claude/hooks/check_sync.sh" @@ -45,7 +45,10 @@ is_skip_file() { local t="$1" for f in "${SKIP_FILES[@]}"; do - [ "$f" = "$t" ] && return 0 + case "$f" in + */) [[ "$t" == "$f"* ]] && return 0 ;; # ディレクトリ指定(末尾 /)は前方一致 + *) [ "$f" = "$t" ] && return 0 ;; + esac done return 1 } @@ -334,7 +337,7 @@ 3. **既存の hooks を保持したまま**,テンプレート側で追加・変更された hook(イベント・matcher・command)のみを統合する: - 既存に無いイベント/hook はテンプレート版から追加する - プロジェクト固有の hook(team の SessionStart `check_sync.sh` 等)は残す - - 同一 hook の command 変更(例: `restrict_repo_access.py` の起動方法変更)はテンプレート版に合わせる + - 同一 hook の command 変更(例: `restrict_repo_access.py` の起動方法変更)はテンプレート版に合わせる.旧形式の `python .claude/hooks/<名前>.py` は `bash .claude/hooks/run_python.sh [--fail-closed] .claude/hooks/<名前>.py` に置き換える(`--fail-closed` はテンプレート版と同じく `restrict_repo_access.py` にのみ付ける).`run_python.sh` が同期で配置済みであることも確認する - solo モードで SessionStart(check_sync) が無い場合は,team 専用の配線を勝手に追加しない 4. `hooks` 以外のキーも同じ方針でマージする.特に `permissions.allow` は,テンプレート側に追加されたルール(`Bash(gh pr merge:*)` 等.auto mode で ops-runner にマージを実行させるために必要)を既存へ追加し,プロジェクトが独自に足したルールは残す(削除しない) 5. `git diff .claude/settings.json` で結果を表示しユーザーに確認する.**permissions の変更はセッション再起動後に反映される**ため,追加があった場合は再起動が必要な旨をユーザーに伝える diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..6d22575 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,54 @@ +--- +name: verify +model: inherit +description: "ローカル環境(およびプロファイルが明示的に許可した検証用環境)で直前の変更の動作とエッジケースを verifier エージェントに確認させ,人間が確認すべき範囲を絞り込む.バグ修正や細かい修正の後,人間の動作確認の前段として使う.検証プロファイル(.claude/verify-profile.md)があるプロジェクトでのみ動作する." +argument-hint: "<確認してほしい内容・観点(省略時は現在の差分から判断する)>" +--- + +あなたは動作確認の司令塔です.確認そのものは **verifier エージェント**に委ね,あなたは**ブリーフの作成**と**結果の中継**を行います. +これは人間の動作確認を**置き換えるものではない**(CLAUDE.md「手動確認が必要な作業」).エッジケース等の機械的に確かめられる部分を先に潰し,人間には残りだけを見てもらうための前段である. + +## ステップ 1: 検証プロファイルの確認 (Profile) + +`.claude/verify-profile.md` が存在するか確認する.**存在しない場合は verifier を起動せず**,以下を伝えて終了する: + +「このプロジェクトでは Claude による自動検証が**未設定**です(`.claude/verify-profile.md` がありません). + +セットアップするには: + +1. `.claude/skills/verify/profile-template.md` を `.claude/verify-profile.md` にコピーする +2. 雛形の各項目(検証手段・検証対象の環境・実行コマンド・前提条件・認証/ロール・テストデータの規約・既知のノイズ・禁止操作・シナリオのテンプレート)をこのプロジェクトの内容で埋める.**検証対象の環境は既定でローカルのみ**であり,staging 等を使わせる場合だけ「影響が無い」と判断した根拠つきで許可リストに書く(書かれていない環境に Claude は接続しない) +3. 雛形の「実行基盤の要件」に従い,シナリオを実行するランナー(起動・実行・集計・後始末)をプロジェクト側に用意する + +用意ができるまでは,動作確認は従来どおり人間が行ってください.雛形の記入を手伝うこともできます.」 + +## ステップ 2: 対象の確認 (Scope) + +1. `git status` と `git diff`(未コミットの変更が無ければ `git diff main...HEAD`)で確認対象の変更を把握する +2. 変更が,プロファイルの検証手段で観測できる挙動に関係しない場合(ドキュメントのみ,設定のみ,テストのみ等)は,verifier を起動せず「今回の変更は自動検証の対象外です」と理由を添えて伝えて終了する +3. 変更が無く `$ARGUMENTS` も無い場合は,何を確認したいかをユーザーに尋ねる +4. 検証の前提を整えるのに破壊的な操作(データの初期化・マイグレーションの適用等)が必要な場合は,無断で実行せず,実施してよいかをユーザーに確認する + +## ステップ 3: verifier に委譲 (Delegate) + +verifier はこの会話を知らないため,以下を含む**自己完結のブリーフ**を渡す: + +- 何を変更したか(実装サマリーがあればそのまま.無ければ差分から要約)と,変更したファイル +- ユーザーが指定した確認観点: `$ARGUMENTS` +- 修正対象のバグがある場合は,その再現手順と期待される挙動 +- 検証の前提について確認済みの事項(ステップ 2 の 4 で確認した内容があれば) + +検証手段・検証対象の環境・実行コマンド・規約はブリーフに書かない(verifier が `.claude/verify-profile.md` を読む).ブリーフでローカル以外の環境へのアクセスを指示しない(許可はプロファイルでのみ与える). + +## ステップ 4: 結果の中継 (Report) + +verifier の検証サマリーを受け取り,判定に応じて提示する: + +- **OK**: 「Claude が確認した項目」の表と「人間が確認すべき残り項目」を提示する.残り項目は省略・要約しすぎない(人間が見る範囲そのものなので) +- **NG**: 発見した不具合を再現手順つきで提示し,修正するかをユーザーに尋ねる.**自分では修正を始めない**(`/implement` の中から呼ばれた場合はそちらの手順に従う) +- **検証不能**: 理由と,人間が解消するための具体的な手順を伝える.プロファイルの記述が実態と合っていないことが原因なら,プロファイルの更新を提案する + +verifier の「後始末」にローカル以外の環境(staging 等)へ残したデータが挙がっていたら,省略せずそのまま伝える(共有環境に残ったままになるため). +「気になった点」や不具合のうち**今回の変更と無関係な既存の問題**が挙がっていたら,今回の変更の問題と混ぜず別扱いで伝え,`/task-create` での起票を案内する. + +いずれの場合も,verifier の OK は**人間の動作確認の代わりにはならない**ことを最後に添える. diff --git a/.claude/skills/verify/profile-template.md b/.claude/skills/verify/profile-template.md new file mode 100644 index 0000000..4a7d4e4 --- /dev/null +++ b/.claude/skills/verify/profile-template.md @@ -0,0 +1,147 @@ +# 検証プロファイル (Verify Profile) + +本ファイルは**雛形**である.`.claude/verify-profile.md` にコピーし,各節の説明と記入例をこのプロジェクトの内容に置き換えて使う. + +```bash +cp .claude/skills/verify/profile-template.md .claude/verify-profile.md +``` + +`.claude/verify-profile.md` が存在するプロジェクトでのみ,`/verify` と `/implement` の Phase 1b(verifier による検証)が動作する.存在しなければ,検証はスキップされ従来どおり人間が動作確認する. +verifier はプロジェクト固有の知識を一切持たず,**このファイルだけ**を頼りに検証する.書かれていないことは推測されるか,実行されない. + +## 使い方 (How to Use) + +- コピー後,各節の「説明」(引用ブロック)は消してよい.verifier に読ませたい内容だけを残す +- 記入例は Web アプリと CLI/API の 2 通りを載せてある.該当しない方は削除する.種別は Web に限らない(デスクトップアプリ・バッチ・ライブラリのサンプル実行等でも,同じ枠に載せられる) +- 実行結果の出力先は `.gitignore` に追加する(スクリーンショット・ログ・一時データをコミットしない) +- 検証中に「プロファイルに書いていなかったせいで verifier が間違えた」ことが起きたら,その場で本ファイルに追記する(次回以降の精度が上がる) +- 本ファイルは**プロジェクト所有**であり,`/sync-template` で上書き・削除されない + +## 種別・検証手段 (Target & Method) + +> 何を,どういう手段で操作して検証するかを 1〜3 行で書く.verifier はここで検証の全体像を掴む. + +- 記入例(Web アプリ): Web アプリケーション.ヘッドレスブラウザ(Playwright + Chromium)でローカルの開発サーバー(`http://localhost:3000`)を操作する.API の確認はブラウザのリクエスト機能で行う +- 記入例(CLI/API): CLI ツール.ビルド済みバイナリをローカルで実行し,標準出力・終了コード・生成ファイルを検証する.HTTP API はローカルサーバーを起動して `curl` 相当のリクエストで確認する + +## 検証対象の環境 (Target Environments) + +> verifier がアクセスしてよい環境を書く.**既定はローカルのみ**であり,この節に何も書かなければローカル以外への接続は行われない. +> staging 等の検証用環境を使わせる場合は,接続先を許可リストとして挙げ,**「いじっても影響が無い」と判断した根拠を環境ごとに書く**. +> 判断の主体は人間(プロジェクト側)であり,**根拠の書かれていない環境は許可していないものとして扱われる**(verifier は,ここに無い接続先を見つけても自分の判断で「影響なし」とみなさない). +> **本番はここに書いても許可されない**(verifier はどんな場合もアクセスしない). + +- ローカルの接続先: `<例: http://localhost:3000(開発サーバー)・localhost:5432(開発 DB)>` +- ローカル以外で許可する環境: `<なし/下表のとおり>`(使わない場合は「なし」と書き,下表を削除する) + +| 環境 | 接続先(許可リスト) | 影響が無いと判断した根拠 | 資格情報の受け渡し | テストデータの後始末 | +| --- | --- | --- | --- | --- | +| `<例: staging>` | `<例: https://staging.example.com,https://api-staging.example.com>` | `<例: 本番とは別の DB を使う/実ユーザーがいない/メール・push 通知・課金が無効化されている>` | `<例: .env.verify.staging(.gitignore 済み.値は人間が配置する.検証専用アカウントのみ)>` | `<例: 「テストデータの規約」の接頭辞に従い,ランナーが実行の成否に関わらず物理削除する>` | + +- 接続を禁止する環境(拒否リスト): `<例: https://example.com,https://api.example.com(本番).どんな場合もアクセスしない>` + +※ 許可した環境は他の開発者や CI も使っている可能性があるため,verifier は自分が作った検証用データだけを操作し,環境全体に効く破壊的な操作(DB のリセット・マイグレーション・デプロイ・設定変更等)は行わない(`.claude/agents/verifier.md`). + +## 実行コマンド (Run Command) + +> シナリオの置き場所・実行方法・出力先を書く.verifier はここに書かれたコマンドしか実行しない(推測で別のコマンドを試させない). + +- シナリオの置き場所: `<例: .verify/<概要(kebab-case)>/scenario.<拡張子>>` +- 実行コマンド: `<例: node scripts/verify/run.mjs <シナリオのパス>>` +- 出力先: `<例: .verify/<概要>/run-<日時>/>`(`.gitignore` 済み).実行ごとに別ディレクトリへ出力され,前回の証拠は上書きされない +- 出力されるもの: `<例: result.json(判定の一覧)・*.png(スクリーンショット)・server.log(サーバーログ)>` + +※ verifier に許可されているコマンド(`.claude/agents/verifier.md` の `tools`)に含まれない実行コマンドは,実行のたびに許可を求められる.常用するなら `.claude/settings.json` の `permissions.allow` に追加するか,許可済みのランタイム経由で実行できる形にしておく. + +## 前提条件と確認方法 (Preconditions) + +> 満たされていなければ「検証不能」とする条件と,その確認コマンドを書く.verifier が**自力で解消してよい操作**があればそれも明記する(書いていない操作はしない). + +| 前提条件 | 確認方法 | 満たされない場合 | +| --- | --- | --- | +| `<例: 依存がインストール済み>` | `<例: ls node_modules>` | `<例: npm ci を実行してよい>` | +| `<例: ローカル DB が起動している>` | `<例: >` | `<例: <起動コマンド> を実行してよい>` | +| `<例: ブラウザ実行環境が導入済み>` | `<例: <バージョン確認コマンド>>` | `<例: 検証不能とし,導入コマンドを人間に案内する>` | +| `<例: 環境変数ファイルがある>` | `<例: ls .env.local>` | `<例: 検証不能(値は人間しか知らないため)>` | + +※ 開発サーバーの起動・停止は実行基盤(ランナー)が行う.verifier に手作業で起動させない. + +## 認証方法・ロール (Auth & Roles) + +> 認証をどう通すか,どのロール・ユーザーで検証できるかを書く.認証が無いプロジェクトは「認証なし」と書く. + +- 記入例(Web アプリ): ヘルパー `launchLoggedIn({ role })` がログイン済みのセッションを返す.`role` は `admin` / `editor` / `viewer` / `null`(未ログイン).ロールごとに別ユーザーなので,権限の確認はロールごとにセッションを分ける +- 記入例(CLI/API): `VERIFY_TOKEN` 環境変数にローカル発行のトークンが入る.ランナーが権限違いのトークンを 3 種類用意する +- 認証情報の扱い: **本番の資格情報は使わない**.ローカル以外では,「検証対象の環境」で許可した環境の**検証専用の資格情報だけ**を使う(受け渡し方法も同節に書く).資格情報を報告に書かない + +## テストデータの規約 (Test Data Rules) + +> 検証で作るデータの命名・後始末・触ってはいけないデータを書く.**検証データが本番やリポジトリに混入する事故はここの不備で起きる**. + +- 命名: 検証で作るデータの名前は必ず `<例: verify->` で始める +- 後始末: ランナーがシナリオ終了後に `<接頭辞>` で始まるデータと従属レコードを**成否に関わらず物理削除**する(論理削除では残る.残ると seed の書き出し等を通じてリポジトリへ混入しうる) +- 触ってはいけないデータ: `<例: シードデータ(既存の組織・ユーザー)は編集・削除しない.更新系の確認は自分で作った verify- データに対して行う>` +- 削除できなかったデータがあれば,verifier は報告の「後始末」に明記する + +## 待ち方・操作の癖 (Waiting & Selectors) + +> フレームワーク固有の注意点.「待てずに落ちる」「セレクタが当たらない」の原因になる癖をここに書く. + +- 記入例(Web アプリ): 画面遷移はクライアント遷移のため通信の完了では待てない.遷移は URL の変化で待ち,描画は目的の要素の出現で待つ.一覧はローディング表示が消えるのを待つ.固定時間の待機は最後の手段 +- 記入例(Web アプリ): セレクタはロール・ラベル・表示文言で指定する.推測で書かず,対象コンポーネントのソースで実際の文言を確認してから書く +- 記入例(Web アプリ): 入力エラーをメッセージではなく送信ボタンの `disabled` で表現している画面がある.対象画面の実装を見て検証方法を選ぶ +- 記入例(CLI/API): 非同期処理の完了は終了コードと出力ファイルの存在で判定する.ログの文言は変わりやすいので判定条件にしない + +## 既知のノイズ (Known Noise) + +> 不具合と誤認しやすい既知の現象.ここに書いていないものは verifier が不具合として報告する. + +- 記入例: 開発モードでは初回描画時にエフェクトが 2 回実行され,1 回だけ失敗するリクエストが記録される(本番ビルドでは起きない) +- 記入例: 計測ツールが挿入するスタイルの影響で,初回描画の差分警告が出ることがある +- 記入例: 初回起動時のみビルドに時間がかかり,最初のリクエストがタイムアウトしやすい(再実行で解消するなら不具合ではない) + +## 禁止操作 (Prohibited Operations) + +> 検証中に実行してはいけない操作.人間のローカル環境を壊す操作は必ずここに書く. + +- `<例: ローカル DB のリセットコマンド(人間の作業データが消える)>` +- `<例: 「検証対象の環境」で許可していない接続先へのアクセス(CLI・MCP 経由を含む).本番への操作は許可リストに関わらず禁止>` +- `<例: 許可した環境(staging 等)での,自分が作った検証データ以外の更新・削除,および環境全体に効く操作(リセット・マイグレーション・デプロイ・設定変更)>` +- 自分が起動していないプロセスの停止(人間が起動済みのサーバーには触らない) +- `<例: マイグレーションの適用(適用は人間に依頼する)>` + +## シナリオのテンプレート (Scenario Template) + +> そのままコピーして動く最小のシナリオを載せる.verifier はこれを出発点にシナリオを書く.ヘルパーの引数と,チェックの書き方(アサーションの方法)を必ず含める. + +```text +<例: 実際に動くシナリオのコードをここに貼る> + +- ヘルパーの一覧と引数(セッションの起動・チェックの記録・アサーション) +- 1 チェック = 「何がどうなるべきか」が分かる名前を付ける +- 失敗しても次のチェックへ進み,成否どちらでも証拠(スクリーンショット・出力)を残す +- 画面を伴わないチェック(API・CLI のみ)の書き方 +- 後始末(作成したデータ・起動したプロセス)はランナー側で行うため,シナリオには書かない +``` + +## 実行基盤の要件 (Runner Requirements) + +> 検証を安定させるための,実行基盤(ランナー)側の要件.シナリオの実行・環境の準備・後始末を担うスクリプトをプロジェクト側に用意し,以下を満たすこと(運用で分かった失敗を踏まえた要件であり,満たさないと検証が不安定になったり事故につながる). + +- **判断はエージェント,決定的な処理はスクリプト**に分ける.サーバーの起動・ログイン・結果の集計・後始末をエージェントに毎回手書きさせない +- **後始末はランナーが,シナリオの成否に関わらず確実に行う**(検証データの物理削除・一時ファイルの削除).エージェントの操作に任せない +- **停止するのは自分が起動したプロセスだけ**にする.プロセス名のパターンで一括終了しない(無関係なプロセスや自分自身を巻き込む).人間が起動済みのサーバーは使うだけで止めない +- **実行ごとに出力ディレクトリを分ける**(`run-<日時>/` 等).再実行で前回のスクリーンショット・結果を上書きしない(不具合の証拠が消える) +- **ローカルと,本ファイルの「検証対象の環境」の許可リスト以外の URL・接続先を拒否するガードをスクリプト側に持つ**.本番のホストは拒否リストとしても持ち,許可リストの設定ミスで到達しないようにする.指示書の禁止事項だけに頼らない +- **計測アーティファクト**(検証ツール自身が挿入する設定・スタイル等で挙動が変わる現象)に注意し,見つけたら本ファイルの「既知のノイズ」に追記する +- 前提条件を満たさない場合は,破壊的な復旧を試みずに**明確なエラーメッセージで終了**する(verifier が「検証不能」と報告できるようにする) + +## 参考実装 (Reference Implementation) + +実行基盤はプロジェクトの認証方式・データストアに強く依存するため,テンプレートには同梱していない.Web アプリ(ヘッドレスブラウザ)向けの実装例は以下を参照する. + +- 導入: [rintoHasegawa/KarutaMatcher PR #93](https://github.com/rintoHasegawa/KarutaMatcher/pull/93) +- 検証データ混入の修正(後始末の設計): [同 PR #94](https://github.com/rintoHasegawa/KarutaMatcher/pull/94) +- 初運用での改善: [同 PR #99](https://github.com/rintoHasegawa/KarutaMatcher/pull/99) + +※ 1 回の検証にかかるコストは規模にもよるが小さくない(目安: 10 分前後).挙動に関係しない変更で走らせないよう,`/implement` の Phase 1b には実行判定がある. diff --git a/.claude/template-sync-sha b/.claude/template-sync-sha index 6f6497b..33da67c 100644 --- a/.claude/template-sync-sha +++ b/.claude/template-sync-sha @@ -1 +1 @@ -0ac68c7d78975e6688d602cd5b7e3fcf9f13b4bf \ No newline at end of file +00dd94e0234076eb545a662e737594413a336066 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/.github/dependabot.yml b/.github/dependabot.yml index 3b67c88..d06f18a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,8 +12,36 @@ open-pull-requests-limit: 5 commit-message: prefix: "[update]" + # グループは記述順に評価され,依存は最初に一致したグループへ入る. + # そのため OpenCvSharp 専用グループを minor-and-patch より先に置く groups: + # OpenCvSharp は OpenCV 本体に追随するバージョニングで semver 契約を持たず, + # 第 2 セグメントの bump でも破壊的変更が入る(4.10→4.13 で ArUco API が変更された). + # よって update-types を指定せず major / minor / patch を区別なく本グループで拾う. + # またマネージド(OpenCvSharp4・Extensions・Windows・WpfExtensions)とネイティブ + # (runtime.win)は同一バージョンで揃える必要があるため,常に 1 つの PR に束ねる(lockstep). + # 本グループは自動マージ禁止(.claude/deps-update-skip.md).実機または fixture 回帰で確認する + opencvsharp: + patterns: + - "OpenCvSharp4" + - "OpenCvSharp4.*" + # groups の applies-to は既定が version-updates のため,上のグループは + # security update 経路には効かない.脆弱性修正で 5 パッケージが個別 PR に割れ, + # マネージドとネイティブが食い違ったままマージされるのを防ぐミラーグループ + opencvsharp-security: + applies-to: "security-updates" + patterns: + - "OpenCvSharp4" + - "OpenCvSharp4.*" + # OpenCvSharp 以外の minor / patch をまとめる. + # patterns の "*" は OpenCvSharp にも一致するため exclude-patterns で明示的に外す + # (記述順による振り分けと合わせた二重の保険) minor-and-patch: + patterns: + - "*" + exclude-patterns: + - "OpenCvSharp4" + - "OpenCvSharp4.*" update-types: - "minor" - "patch" diff --git a/.gitignore b/.gitignore index 7109241..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 @@ -27,3 +30,4 @@ .claude/deps-update-merged.md .claude/deps-update-report.md .claude/deps-update-skip.md +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index 66f12c4..bb332a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: テンプレートを最新版(`0ac68c7`)に同期し,補助エージェント(advisor/deps-analyst/ops-runner)・`/consult`・`/deps-update`・`/commit merge`・テンプレート改変台帳を導入.あわせて Dependabot(alerts / security updates の有効化・`.github/dependabot.yml`)をセットアップ +最新: OpenCvSharp 4.13 の ArUco API 移行(#31・PR #36)を main に追従させて実機確認のうえマージした ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) @@ -25,7 +25,8 @@ - ブランチ名・コミットメッセージの書式は `.claude/rules/git-conventions.md` に従う(常時ロードされる) - コミットは `/commit` を使用する(push・PR 作成は `/commit push`,マージ・プルまで行う場合は `/commit merge`) - **`/commit` はユーザーが明示的に指示した時のみ実行する.Claude が自発的に `/commit` や `git commit` を呼んではならない**(`/implement` 完了後も,案内するだけで自分ではコミットしない.`/commit` skill は `disable-model-invocation` によりユーザー起動限定として強制されている) - - **例外**: 以下の無人運転ループはユーザー承認済みの例外として専用ブランチに自律コミットする.いずれも push・PR・マージ・`main` への操作はしない(取り込みは人間が `/commit push` 等で行う) + - **例外**: 以下(プロジェクト立ち上げと無人運転ループ)はユーザー承認済みの例外として専用ブランチに自律コミットする.いずれも push・PR・マージ・`main` への操作はしない(取り込みは人間が `/commit push` 等で行う) + - `/setup`(プロジェクト立ち上げ)→ `chore/project-setup` 専用ブランチ(フェーズごとに 1 コミット) - `/auto-refactor`(リファクタ/ドキュメント整理ループ)→ `refactor/` 専用ブランチ - `/auto-audit`(バグ/脆弱性の巡回監査ループ)→ `fix/` 専用ブランチ - **例外(`main` へのマージ)**: `/deps-update` は,Dependabot が作成した依存更新 PR に限り,ゲート(メジャー更新でない・競合なし・CI 緑またはローカル検証緑)を満たすものを `gh pr merge` で `main` に取り込む(ユーザーが明示的に起動した時のみ.メジャー更新・CI 赤・PR が付かない alert は報告して人間が判断する) @@ -48,6 +49,7 @@ - Phase 1 後に人間が動作確認,Phase 2 でテスト失敗時のみ方針判断 - Phase 2 成功後は Phase 3 → Phase 4 まで自動で進む(Phase 4 で更新がある場合のみ確認) - テスト・リファクタリングをスキップしない +- 検証プロファイル(`.claude/verify-profile.md`)を用意したプロジェクトでは,Phase 1 の後に Phase 1b(verifier による動作確認.対象はローカル環境と,プロファイルが根拠つきで許可した検証用環境のみ.本番は対象外)が入り,人間が確認する範囲が絞られる(単独実行は `/verify`).プロファイルが無ければ Phase 1b はスキップされ,動作確認は従来どおり人間が行う.**verifier の OK は人間の動作確認の代わりにならない** ### 開発コマンド @@ -60,7 +62,7 @@ - 外部サービスの設定(コンソール操作,セキュリティルール変更等) - 例外: `gh` CLI で操作できる GitHub リポジトリ設定(Dependabot alerts / security updates の有効化)は Claude が `gh api` で行う(手順は `.claude/skills/setup/reference.md`,背景は GUIDE_01「環境構築」) -- 実機・ブラウザでの動作確認 +- 実機・ブラウザでの動作確認(`verifier` による自動検証は前段であり,これを置き換えない) - ストアへのアップロード・リリース作業 ## ドキュメント diff --git a/TIASshot.Tests/BitmapUtilTests.cs b/TIASshot.Tests/BitmapUtilTests.cs index 05a7041..0198304 100644 --- a/TIASshot.Tests/BitmapUtilTests.cs +++ b/TIASshot.Tests/BitmapUtilTests.cs @@ -33,9 +33,9 @@ public void FastClone_24bppRgb_サイズとPixelFormatが入力と一致する() { using (var src = CreateSolidBitmap(4, 3, PixelFormat.Format24bppRgb, Color.FromArgb(10, 20, 30))) using (var dst = BitmapUtil.FastClone(src)) { - Assert.AreEqual(src.Width, dst.Width, "幅が一致すること"); - Assert.AreEqual(src.Height, dst.Height, "高さが一致すること"); - Assert.AreEqual(src.PixelFormat, dst.PixelFormat, "PixelFormat が一致すること"); + Assert.That(dst.Width, Is.EqualTo(src.Width), "幅が一致すること"); + Assert.That(dst.Height, Is.EqualTo(src.Height), "高さが一致すること"); + Assert.That(dst.PixelFormat, Is.EqualTo(src.PixelFormat), "PixelFormat が一致すること"); } } @@ -57,9 +57,9 @@ public void FastClone_32bppArgb_サイズとPixelFormatが入力と一致する() { using (var src = CreateSolidBitmap(5, 4, PixelFormat.Format32bppArgb, Color.FromArgb(200, 10, 20, 30))) using (var dst = BitmapUtil.FastClone(src)) { - Assert.AreEqual(src.Width, dst.Width, "幅が一致すること"); - Assert.AreEqual(src.Height, dst.Height, "高さが一致すること"); - Assert.AreEqual(src.PixelFormat, dst.PixelFormat, "PixelFormat が一致すること"); + Assert.That(dst.Width, Is.EqualTo(src.Width), "幅が一致すること"); + Assert.That(dst.Height, Is.EqualTo(src.Height), "高さが一致すること"); + Assert.That(dst.PixelFormat, Is.EqualTo(src.PixelFormat), "PixelFormat が一致すること"); } } @@ -88,13 +88,13 @@ src.Dispose(); try { - Assert.AreEqual(4, dst.Width, "src Dispose 後も dst の幅が読めること"); - Assert.AreEqual(4, dst.Height, "src Dispose 後も dst の高さが読めること"); + Assert.That(dst.Width, Is.EqualTo(4), "src Dispose 後も dst の幅が読めること"); + Assert.That(dst.Height, Is.EqualTo(4), "src Dispose 後も dst の高さが読めること"); Color px = dst.GetPixel(0, 0); - Assert.AreEqual(11, px.R, "src Dispose 後も dst の画素(R)が読めること"); - Assert.AreEqual(22, px.G, "src Dispose 後も dst の画素(G)が読めること"); - Assert.AreEqual(33, px.B, "src Dispose 後も dst の画素(B)が読めること"); + Assert.That(px.R, Is.EqualTo(11), "src Dispose 後も dst の画素(R)が読めること"); + Assert.That(px.G, Is.EqualTo(22), "src Dispose 後も dst の画素(G)が読めること"); + Assert.That(px.B, Is.EqualTo(33), "src Dispose 後も dst の画素(B)が読めること"); } finally { dst.Dispose(); } @@ -110,9 +110,9 @@ dst.SetPixel(0, 0, Color.FromArgb(0, 0, 0)); Color srcPx = src.GetPixel(0, 0); - Assert.AreEqual(50, srcPx.R, "dst 変更後も src の R は不変"); - Assert.AreEqual(60, srcPx.G, "dst 変更後も src の G は不変"); - Assert.AreEqual(70, srcPx.B, "dst 変更後も src の B は不変"); + Assert.That(srcPx.R, Is.EqualTo(50), "dst 変更後も src の R は不変"); + Assert.That(srcPx.G, Is.EqualTo(60), "dst 変更後も src の G は不変"); + Assert.That(srcPx.B, Is.EqualTo(70), "dst 変更後も src の B は不変"); } } @@ -131,14 +131,14 @@ Color beforeCorner = src.GetPixel(src.Width - 1, src.Height - 1); using (var dst = BitmapUtil.FastClone(src)) { - Assert.IsNotNull(dst); + Assert.That(dst, Is.Not.Null); } Color after00 = src.GetPixel(0, 0); Color afterCorner = src.GetPixel(src.Width - 1, src.Height - 1); - Assert.AreEqual(before00, after00, "FastClone 呼び出し前後で (0,0) の画素が不変であること"); - Assert.AreEqual(beforeCorner, afterCorner, "FastClone 呼び出し前後で末尾画素が不変であること"); + Assert.That(after00, Is.EqualTo(before00), "FastClone 呼び出し前後で (0,0) の画素が不変であること"); + Assert.That(afterCorner, Is.EqualTo(beforeCorner), "FastClone 呼び出し前後で末尾画素が不変であること"); } } @@ -165,8 +165,8 @@ public void FastClone_幅が4の倍数でない24bppRgb_サイズが一致する() { using (var src = CreateGradientBitmap(5, 3, PixelFormat.Format24bppRgb)) using (var dst = BitmapUtil.FastClone(src)) { - Assert.AreEqual(5, dst.Width); - Assert.AreEqual(3, dst.Height); + Assert.That(dst.Width, Is.EqualTo(5)); + Assert.That(dst.Height, Is.EqualTo(3)); } } @@ -202,19 +202,19 @@ SetIndexedPixel(src, 3, 3, 255); Bitmap dst = null; - Assert.DoesNotThrow(() => dst = BitmapUtil.FastClone(src), + Assert.DoesNotThrow((Action)(() => dst = BitmapUtil.FastClone(src)), "8bppIndexed を渡しても例外をスローしないこと"); try { - Assert.IsNotNull(dst, "8bppIndexed のフォールバックコピーは null であってはならない"); - Assert.AreNotSame(src, dst, "入力とは別インスタンスであること"); - Assert.AreEqual(src.Width, dst.Width, "幅が一致すること"); - Assert.AreEqual(src.Height, dst.Height, "高さが一致すること"); + Assert.That(dst, Is.Not.Null, "8bppIndexed のフォールバックコピーは null であってはならない"); + Assert.That(dst, Is.Not.SameAs(src), "入力とは別インスタンスであること"); + Assert.That(dst.Width, Is.EqualTo(src.Width), "幅が一致すること"); + Assert.That(dst.Height, Is.EqualTo(src.Height), "高さが一致すること"); // 解決済みの色(パレット適用後の ARGB)が画素ごとに一致すること - Assert.AreEqual(src.GetPixel(0, 0), dst.GetPixel(0, 0), "(0,0) の色が一致すること"); - Assert.AreEqual(src.GetPixel(1, 1), dst.GetPixel(1, 1), "(1,1) の色が一致すること"); - Assert.AreEqual(src.GetPixel(3, 3), dst.GetPixel(3, 3), "(3,3) の色が一致すること"); + Assert.That(dst.GetPixel(0, 0), Is.EqualTo(src.GetPixel(0, 0)), "(0,0) の色が一致すること"); + Assert.That(dst.GetPixel(1, 1), Is.EqualTo(src.GetPixel(1, 1)), "(1,1) の色が一致すること"); + Assert.That(dst.GetPixel(3, 3), Is.EqualTo(src.GetPixel(3, 3)), "(3,3) の色が一致すること"); // 独立性: src を Dispose 後も dst の画素が読めること } finally { @@ -242,7 +242,7 @@ try { Color actual = dst.GetPixel(0, 0); - Assert.AreEqual(expected, actual, "src Dispose 後も dst の色が読めること"); + Assert.That(actual, Is.EqualTo(expected), "src Dispose 後も dst の色が読めること"); } finally { dst.Dispose(); } @@ -258,7 +258,7 @@ [Test] public void FastClone_null入力_nullを返す() { Bitmap result = BitmapUtil.FastClone(null); - Assert.IsNull(result, "null 入力に対して null を返すこと(例外をスローしない)"); + Assert.That(result, Is.Null, "null 入力に対して null を返すこと(例外をスローしない)"); } // --------------------------------------------------------------- @@ -314,9 +314,9 @@ /// LockBits で生バイトを取得し,行ごとに有効バイト幅(パディング除外)だけを比較する。 /// private static void AssertPixelsEqual(Bitmap expected, Bitmap actual) { - Assert.AreEqual(expected.Width, actual.Width, "幅が一致すること(画素比較の前提)"); - Assert.AreEqual(expected.Height, actual.Height, "高さが一致すること(画素比較の前提)"); - Assert.AreEqual(expected.PixelFormat, actual.PixelFormat, "PixelFormat が一致すること(画素比較の前提)"); + Assert.That(actual.Width, Is.EqualTo(expected.Width), "幅が一致すること(画素比較の前提)"); + Assert.That(actual.Height, Is.EqualTo(expected.Height), "高さが一致すること(画素比較の前提)"); + Assert.That(actual.PixelFormat, Is.EqualTo(expected.PixelFormat), "PixelFormat が一致すること(画素比較の前提)"); var rect = new Rectangle(0, 0, expected.Width, expected.Height); BitmapData expData = expected.LockBits(rect, ImageLockMode.ReadOnly, expected.PixelFormat); @@ -335,7 +335,7 @@ IntPtr.Add(actData.Scan0, y * actData.Stride), actRow, 0, validBytesPerRow); for (int i = 0; i < validBytesPerRow; i++) { - Assert.AreEqual(expRow[i], actRow[i], + Assert.That(actRow[i], Is.EqualTo(expRow[i]), $"行 y={y} のバイト位置 {i}(パディング除外後)が一致すること"); } } diff --git a/TIASshot.Tests/ColorCorrectorTests.cs b/TIASshot.Tests/ColorCorrectorTests.cs index b06447b..8642ef9 100644 --- a/TIASshot.Tests/ColorCorrectorTests.cs +++ b/TIASshot.Tests/ColorCorrectorTests.cs @@ -31,8 +31,8 @@ // Act using (var dst = corrector.ExtendMat(src, channels: 4)) { // Assert - Assert.AreEqual(2, dst.Rows, "行数は変わらない"); - Assert.AreEqual(4, dst.Cols, "列数は指定した channels 数になる"); + Assert.That(dst.Rows, Is.EqualTo(2), "行数は変わらない"); + Assert.That(dst.Cols, Is.EqualTo(4), "列数は指定した channels 数になる"); } } } @@ -54,7 +54,7 @@ using (var dst = corrector.ExtendMat(src, channels: 4)) { // Assert: 列 0 は全行で 1.0 for (int row = 0; row < dst.Rows; row++) { - Assert.AreEqual(1.0, dst.At(row, 0), 1e-10, + Assert.That(dst.At(row, 0), Is.EqualTo(1.0).Within(1e-10), $"row={row}: 列0(定数項)は 1.0 であること"); } } @@ -76,11 +76,11 @@ using (var dst = corrector.ExtendMat(src, channels: 4)) { // Assert // ExtendChannels[1]={0,3,3}: vals[0]*vals[3]*vals[3] = R*1*1 = 30 - Assert.AreEqual(30.0, dst.At(0, 1), 1e-10, "列1 は R"); + Assert.That(dst.At(0, 1), Is.EqualTo(30.0).Within(1e-10), "列1 は R"); // ExtendChannels[2]={1,3,3}: vals[1]*vals[3]*vals[3] = G*1*1 = 20 - Assert.AreEqual(20.0, dst.At(0, 2), 1e-10, "列2 は G"); + Assert.That(dst.At(0, 2), Is.EqualTo(20.0).Within(1e-10), "列2 は G"); // ExtendChannels[3]={2,3,3}: vals[2]*vals[3]*vals[3] = B*1*1 = 10 - Assert.AreEqual(10.0, dst.At(0, 3), 1e-10, "列3 は B"); + Assert.That(dst.At(0, 3), Is.EqualTo(10.0).Within(1e-10), "列3 は B"); } } } @@ -99,7 +99,7 @@ // Act using (var dst = corrector.ExtendMat(src, channels: 5)) { // Assert: 列4(i=4)= R*G = 30*20 = 600 - Assert.AreEqual(600.0, dst.At(0, 4), 1e-10, "列4 は R*G = 30*20 = 600"); + Assert.That(dst.At(0, 4), Is.EqualTo(600.0).Within(1e-10), "列4 は R*G = 30*20 = 600"); } } } @@ -117,7 +117,7 @@ var dst = corrector.ExtendMat(src, channels: 4); // Assert: 列数が変わらない(src のまま返る) - Assert.AreEqual(5, dst.Cols, "3列でない入力はそのまま返す"); + Assert.That(dst.Cols, Is.EqualTo(5), "3列でない入力はそのまま返す"); // dst == src の参照なので Dispose しない(src の using で解放される) } } @@ -139,8 +139,8 @@ // Act using (var conv = corrector.CalcConvertMatrix(src, target, channels: 4)) { // Assert: 変換行列は channels x 3 = 4 x 3 - Assert.AreEqual(4, conv.Rows, "行数(channels)は 4 であること"); - Assert.AreEqual(3, conv.Cols, "列数は 3(RGB チャネル)であること"); + Assert.That(conv.Rows, Is.EqualTo(4), "行数(channels)は 4 であること"); + Assert.That(conv.Cols, Is.EqualTo(3), "列数は 3(RGB チャネル)であること"); } } } @@ -158,8 +158,8 @@ // Act using (var conv = corrector.CalcConvertMatrix(src, target, channels: 6)) { // Assert - Assert.AreEqual(6, conv.Rows, "行数(channels)は 6 であること"); - Assert.AreEqual(3, conv.Cols, "列数は 3(RGB チャネル)であること"); + Assert.That(conv.Rows, Is.EqualTo(6), "行数(channels)は 6 であること"); + Assert.That(conv.Cols, Is.EqualTo(3), "列数は 3(RGB チャネル)であること"); } } } @@ -187,11 +187,11 @@ // 行0(定数項)=0, 行1(R)=[1,0,0], 行2(G)=[0,1,0], 行3(B)=[0,0,1] using (var dst = corrector.ConvertColor(src, conv)) { // Assert: 出力は 1x3, 値は B=10, G=20, R=30 - Assert.AreEqual(1, dst.Rows, "出力行数は入力と同じ"); - Assert.AreEqual(3, dst.Cols, "出力列数は 3(RGB)"); - Assert.AreEqual(10.0, dst.At(0, 0), 1e-10, "出力 B = 10"); - Assert.AreEqual(20.0, dst.At(0, 1), 1e-10, "出力 G = 20"); - Assert.AreEqual(30.0, dst.At(0, 2), 1e-10, "出力 R = 30"); + Assert.That(dst.Rows, Is.EqualTo(1), "出力行数は入力と同じ"); + Assert.That(dst.Cols, Is.EqualTo(3), "出力列数は 3(RGB)"); + Assert.That(dst.At(0, 0), Is.EqualTo(10.0).Within(1e-10), "出力 B = 10"); + Assert.That(dst.At(0, 1), Is.EqualTo(20.0).Within(1e-10), "出力 G = 20"); + Assert.That(dst.At(0, 2), Is.EqualTo(30.0).Within(1e-10), "出力 R = 30"); } } } @@ -220,8 +220,8 @@ // Act using (var dst = corrector.ConvertColor(src, conv)) { // Assert: 出力は 5行 3列 - Assert.AreEqual(5, dst.Rows, "出力行数は入力行数と同じ"); - Assert.AreEqual(3, dst.Cols, "出力列数は 3(RGB)"); + Assert.That(dst.Rows, Is.EqualTo(5), "出力行数は入力行数と同じ"); + Assert.That(dst.Cols, Is.EqualTo(3), "出力列数は 3(RGB)"); } } } @@ -308,9 +308,9 @@ // Act using (var dst = corrector.ConvertImage(src, conv)) { // Assert: 型・サイズ - Assert.AreEqual(MatType.CV_8UC3, dst.Type(), "出力型は CV_8UC3"); - Assert.AreEqual(2, dst.Rows, "出力行数は入力と同じ"); - Assert.AreEqual(2, dst.Cols, "出力列数は入力と同じ"); + Assert.That(dst.Type(), Is.EqualTo(MatType.CV_8UC3), "出力型は CV_8UC3"); + Assert.That(dst.Rows, Is.EqualTo(2), "出力行数は入力と同じ"); + Assert.That(dst.Cols, Is.EqualTo(2), "出力列数は入力と同じ"); } } } @@ -335,9 +335,9 @@ using (var dst = corrector.ConvertImage(src, conv)) { // Assert: 出力ピクセルが入力と同一値 var pixel = dst.At(0, 0); - Assert.AreEqual(50, pixel.Item0, "出力 B = 50"); - Assert.AreEqual(100, pixel.Item1, "出力 G = 100"); - Assert.AreEqual(150, pixel.Item2, "出力 R = 150"); + Assert.That(pixel.Item0, Is.EqualTo(50), "出力 B = 50"); + Assert.That(pixel.Item1, Is.EqualTo(100), "出力 G = 100"); + Assert.That(pixel.Item2, Is.EqualTo(150), "出力 R = 150"); } } } @@ -359,9 +359,9 @@ for (int row = 0; row < dst.Rows; row++) { for (int col = 0; col < dst.Cols; col++) { var pixel = dst.At(row, col); - Assert.AreEqual(0, pixel.Item0, $"row={row}, col={col}: B は 0"); - Assert.AreEqual(0, pixel.Item1, $"row={row}, col={col}: G は 0"); - Assert.AreEqual(0, pixel.Item2, $"row={row}, col={col}: R は 0"); + Assert.That(pixel.Item0, Is.EqualTo(0), $"row={row}, col={col}: B は 0"); + Assert.That(pixel.Item1, Is.EqualTo(0), $"row={row}, col={col}: G は 0"); + Assert.That(pixel.Item2, Is.EqualTo(0), $"row={row}, col={col}: R は 0"); } } } @@ -385,16 +385,16 @@ using (var conv = CreateIdentityLikeConvMat()) { // Act: 例外が発生しないことも検証(解放済み Mat へのアクセスがないこと) Mat dst = null; - Assert.DoesNotThrow(() => dst = corrector.ConvertImage(src, conv), + Assert.DoesNotThrow((System.Action)(() => dst = corrector.ConvertImage(src, conv)), $"呼び出し {call + 1} 回目で例外が発生した"); // Assert: 出力が正しいこと(解放済み Mat からの読み出しでないことの確認) using (dst) { - Assert.AreEqual(MatType.CV_8UC3, dst.Type(), $"呼び出し {call + 1} 回目: 型は CV_8UC3"); + Assert.That(dst.Type(), Is.EqualTo(MatType.CV_8UC3), $"呼び出し {call + 1} 回目: 型は CV_8UC3"); var pixel = dst.At(0, 0); - Assert.AreEqual(50, pixel.Item0, $"呼び出し {call + 1} 回目: B = 50"); - Assert.AreEqual(100, pixel.Item1, $"呼び出し {call + 1} 回目: G = 100"); - Assert.AreEqual(150, pixel.Item2, $"呼び出し {call + 1} 回目: R = 150"); + Assert.That(pixel.Item0, Is.EqualTo(50), $"呼び出し {call + 1} 回目: B = 50"); + Assert.That(pixel.Item1, Is.EqualTo(100), $"呼び出し {call + 1} 回目: G = 100"); + Assert.That(pixel.Item2, Is.EqualTo(150), $"呼び出し {call + 1} 回目: R = 150"); } } } @@ -413,12 +413,12 @@ using (var conv = CreateIdentityLikeConvMat()) { var corrector = MakeCorrectorWith4Channels(); - Assert.AreEqual(MatType.CV_8UC3, src.Type(), "呼び出し前は CV_8UC3"); + Assert.That(src.Type(), Is.EqualTo(MatType.CV_8UC3), "呼び出し前は CV_8UC3"); // Act using (var dst = corrector.ConvertImage(src, conv)) { // Assert: 1 回目の呼び出し後、src は CV_64FC3 に変化している(in-place 書き換えの既知挙動) - Assert.AreEqual(MatType.CV_64FC3, src.Type(), + Assert.That(src.Type(), Is.EqualTo(MatType.CV_64FC3), "ConvertImage 呼び出し後、src は in-place で CV_64FC3 に変換される(優先度4 の既知挙動)"); } } @@ -452,25 +452,25 @@ foreach (var kv in firstSaved) { var name = Path.GetFileName(kv.Key); if (name.Contains("convrgb2xyz") || name.Contains("tccsrgb") || name.Contains("convsrgb2xyz")) { - Assert.IsTrue(kv.Value.IsDisposed, $"中間 Mat は解放済みのはず: {name}"); + Assert.That(kv.Value.IsDisposed, Is.True, $"中間 Mat は解放済みのはず: {name}"); } } // 入力 tccRgb は ColorCorrector に破棄されない(所有権は呼び出し側) var tccRgbSaved = firstSaved.First(kv => Path.GetFileName(kv.Key).Contains("tccrgb")).Value; - Assert.IsFalse(tccRgbSaved.IsDisposed, "入力 tccRgb は破棄されない(所有権は呼び出し側)"); - Assert.IsFalse(tccRgb.IsDisposed, "入力 tccRgb 本体も生存している"); + Assert.That(tccRgbSaved.IsDisposed, Is.False, "入力 tccRgb は破棄されない(所有権は呼び出し側)"); + Assert.That(tccRgb.IsDisposed, Is.False, "入力 tccRgb 本体も生存している"); // 保持される変換行列(ConvRgb2Srgb, ch4)は 1 回目校正後に生存 var keptCh4 = firstSaved.First(kv => { var n = Path.GetFileName(kv.Key); return n.Contains("convrgb2srgb") && n.Contains("04"); }).Value; - Assert.IsFalse(keptCh4.IsDisposed, "保持中の変換行列は 1 回目校正後に生存している"); + Assert.That(keptCh4.IsDisposed, Is.False, "保持中の変換行列は 1 回目校正後に生存している"); // 2回目の校正:旧エントリ(1回目の保持行列)が破棄される corrector.CalcTcc(tccRgb, "dummy", (path, mat) => { }); - Assert.IsTrue(keptCh4.IsDisposed, "再校正で古い変換行列(旧エントリ)が破棄される"); + Assert.That(keptCh4.IsDisposed, Is.True, "再校正で古い変換行列(旧エントリ)が破棄される"); } } @@ -508,11 +508,11 @@ // Act using (var conv = corrector.CalcConvertMatrix(src, target, channels: 4)) { // Assert - Assert.AreEqual(1, corrector.CapturedExtended.Count, "ExtendMat は 1 回呼ばれる"); + Assert.That(corrector.CapturedExtended.Count, Is.EqualTo(1), "ExtendMat は 1 回呼ばれる"); var extended = corrector.CapturedExtended[0]; - Assert.AreNotSame(src, extended, "通常入力では新規 Mat が確保される(前提確認)"); - Assert.IsTrue(extended.IsDisposed, "中間拡張 Mat は CalcConvertMatrix 完了時に解放される"); - Assert.IsFalse(src.IsDisposed, "入力 src は解放されない(所有権は呼び出し側)"); + Assert.That(extended, Is.Not.SameAs(src), "通常入力では新規 Mat が確保される(前提確認)"); + Assert.That(extended.IsDisposed, Is.True, "中間拡張 Mat は CalcConvertMatrix 完了時に解放される"); + Assert.That(src.IsDisposed, Is.False, "入力 src は解放されない(所有権は呼び出し側)"); } } } @@ -532,11 +532,11 @@ // Act using (var dst = corrector.ConvertColor(src, conv)) { // Assert - Assert.AreEqual(1, corrector.CapturedExtended.Count, "ExtendMat は 1 回呼ばれる"); + Assert.That(corrector.CapturedExtended.Count, Is.EqualTo(1), "ExtendMat は 1 回呼ばれる"); var extended = corrector.CapturedExtended[0]; - Assert.AreNotSame(src, extended, "通常入力では新規 Mat が確保される(前提確認)"); - Assert.IsTrue(extended.IsDisposed, "中間拡張 Mat は ConvertColor 完了時に解放される"); - Assert.IsFalse(src.IsDisposed, "入力 src は解放されない(所有権は呼び出し側)"); + Assert.That(extended, Is.Not.SameAs(src), "通常入力では新規 Mat が確保される(前提確認)"); + Assert.That(extended.IsDisposed, Is.True, "中間拡張 Mat は ConvertColor 完了時に解放される"); + Assert.That(src.IsDisposed, Is.False, "入力 src は解放されない(所有権は呼び出し側)"); } } } @@ -560,8 +560,8 @@ // Act using (var conv = corrector.CalcConvertMatrix(src, target, channels: 4)) { // Assert - Assert.AreSame(src, corrector.CapturedExtended[0], "パススルー入力では src がそのまま返る(前提確認)"); - Assert.IsFalse(src.IsDisposed, "パススルー入力の src は解放されない(所有権は呼び出し側)"); + Assert.That(corrector.CapturedExtended[0], Is.SameAs(src), "パススルー入力では src がそのまま返る(前提確認)"); + Assert.That(src.IsDisposed, Is.False, "パススルー入力の src は解放されない(所有権は呼び出し側)"); } } } @@ -580,8 +580,8 @@ // Act using (var dst = corrector.ConvertColor(src, conv)) { // Assert - Assert.AreSame(src, corrector.CapturedExtended[0], "パススルー入力では src がそのまま返る(前提確認)"); - Assert.IsFalse(src.IsDisposed, "パススルー入力の src は解放されない(所有権は呼び出し側)"); + Assert.That(corrector.CapturedExtended[0], Is.SameAs(src), "パススルー入力では src がそのまま返る(前提確認)"); + Assert.That(src.IsDisposed, Is.False, "パススルー入力の src は解放されない(所有権は呼び出し側)"); } } } diff --git a/TIASshot.Tests/ConfigTests.cs b/TIASshot.Tests/ConfigTests.cs index 6d6b36b..c5c3498 100644 --- a/TIASshot.Tests/ConfigTests.cs +++ b/TIASshot.Tests/ConfigTests.cs @@ -52,7 +52,7 @@ [Test] public void GetFloat_存在するキー_正しい値を返す() { var value = Config.GetFloat("FloatVal"); - Assert.AreEqual(ExpectedFloat, value, 1e-6f); + Assert.That(value, Is.EqualTo(ExpectedFloat).Within(1e-6f)); } /// @@ -61,7 +61,7 @@ [Test] public void GetInt_存在するキー_正しい値を返す() { var value = Config.GetInt("IntVal"); - Assert.AreEqual(ExpectedInt, value); + Assert.That(value, Is.EqualTo(ExpectedInt)); } /// @@ -70,7 +70,7 @@ [Test] public void GetString_存在するキー_正しい値を返す() { var value = Config.GetString("StrVal"); - Assert.AreEqual(ExpectedString, value); + Assert.That(value, Is.EqualTo(ExpectedString)); } /// @@ -78,7 +78,7 @@ /// [Test] public void GetFloat_存在しないキー_ArgumentExceptionをスロー() { - Assert.Throws(() => Config.GetFloat("NoSuchKey")); + Assert.Throws((Action)(() => Config.GetFloat("NoSuchKey"))); } /// @@ -86,7 +86,7 @@ /// [Test] public void GetInt_存在しないキー_ArgumentExceptionをスロー() { - Assert.Throws(() => Config.GetInt("NoSuchKey")); + Assert.Throws((Action)(() => Config.GetInt("NoSuchKey"))); } /// @@ -94,7 +94,7 @@ /// [Test] public void GetString_存在しないキー_ArgumentExceptionをスロー() { - Assert.Throws(() => Config.GetString("NoSuchKey")); + Assert.Throws((Action)(() => Config.GetString("NoSuchKey"))); } /// @@ -103,7 +103,7 @@ [Test] public void GetString_ネスト要素_正しい値を返す() { var value = Config.GetString("Nested/Child"); - Assert.AreEqual(ExpectedNested, value); + Assert.That(value, Is.EqualTo(ExpectedNested)); } /// @@ -112,7 +112,7 @@ /// [Test] public void GetInt_数値でない値_ArgumentExceptionをスロー() { - Assert.Throws(() => Config.GetInt("BadNumVal")); + Assert.Throws((Action)(() => Config.GetInt("BadNumVal"))); } /// @@ -120,7 +120,7 @@ /// [Test] public void GetFloat_数値でない値_ArgumentExceptionをスロー() { - Assert.Throws(() => Config.GetFloat("BadNumVal")); + Assert.Throws((Action)(() => Config.GetFloat("BadNumVal"))); } /// @@ -128,7 +128,7 @@ /// [Test] public void GetInt_空の値_ArgumentExceptionをスロー() { - Assert.Throws(() => Config.GetInt("EmptyVal")); + Assert.Throws((Action)(() => Config.GetInt("EmptyVal"))); } /// @@ -142,7 +142,7 @@ System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE"); var value = Config.GetFloat("FloatVal"); - Assert.AreEqual(ExpectedFloat, value, 1e-6f); + Assert.That(value, Is.EqualTo(ExpectedFloat).Within(1e-6f)); } finally { System.Threading.Thread.CurrentThread.CurrentCulture = original; } @@ -157,7 +157,7 @@ var repoConfigPath = Path.GetFullPath(Path.Combine( TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "TIASshot", "config.xml")); - Assert.IsTrue(File.Exists(repoConfigPath), $"同梱 config.xml が見つからない: {repoConfigPath}"); + Assert.That(File.Exists(repoConfigPath), Is.True, $"同梱 config.xml が見つからない: {repoConfigPath}"); Config.Load(repoConfigPath); string[] intKeys = { @@ -172,14 +172,14 @@ "Calib/Reference/B", "Calib/Reference/G", "Calib/Reference/R", }; foreach (var key in intKeys) { - Assert.DoesNotThrow(() => Config.GetInt(key), $"GetInt(\"{key}\") が例外を投げた"); + Assert.DoesNotThrow((Action)(() => Config.GetInt(key)), $"GetInt(\"{key}\") が例外を投げた"); } foreach (var key in floatKeys) { - Assert.DoesNotThrow(() => Config.GetFloat(key), $"GetFloat(\"{key}\") が例外を投げた"); + Assert.DoesNotThrow((Action)(() => Config.GetFloat(key)), $"GetFloat(\"{key}\") が例外を投げた"); } // ゼロ除算・校正停止に直結する値が有効値で読めることを固定する - Assert.AreNotEqual(0, Config.GetInt("Calib/UpdateInterval")); - Assert.AreNotEqual(0.0f, Config.GetFloat("Calib/UpdateRate")); + Assert.That(Config.GetInt("Calib/UpdateInterval"), Is.Not.EqualTo(0)); + Assert.That(Config.GetFloat("Calib/UpdateRate"), Is.Not.EqualTo(0.0f)); } } } diff --git a/TIASshot.Tests/GainCalcCalcGainValueTests.cs b/TIASshot.Tests/GainCalcCalcGainValueTests.cs index 56e92d3..2a331ca 100644 --- a/TIASshot.Tests/GainCalcCalcGainValueTests.cs +++ b/TIASshot.Tests/GainCalcCalcGainValueTests.cs @@ -20,7 +20,7 @@ [Test] public void CalcGainValue_比率が範囲内_従来どおり四捨五入した値を返す() { int result = GainCalc.CalcGainValue(100, 1.5f, 0, 255); - Assert.AreEqual(150, result); + Assert.That(result, Is.EqualTo(150)); } /// @@ -30,7 +30,7 @@ [Test] public void CalcGainValue_小数部が切り上がる場合_四捨五入した値を返す() { int result = GainCalc.CalcGainValue(100, 1.006f, 0, 255); - Assert.AreEqual(101, result); + Assert.That(result, Is.EqualTo(101)); } /// @@ -40,7 +40,7 @@ [Test] public void CalcGainValue_比率が1_現在値をそのまま返す() { int result = GainCalc.CalcGainValue(133, 1.0f, 0, 255); - Assert.AreEqual(133, result); + Assert.That(result, Is.EqualTo(133)); } // --------------------------------------------------------------- @@ -55,7 +55,7 @@ [Test] public void CalcGainValue_結果がrangeMaxを超える_rangeMaxを返す() { int result = GainCalc.CalcGainValue(133, 2.008f, 0, 255); - Assert.AreEqual(255, result); + Assert.That(result, Is.EqualTo(255)); } /// @@ -65,7 +65,7 @@ [Test] public void CalcGainValue_結果がrangeMin未満_rangeMinを返す() { int result = GainCalc.CalcGainValue(10, 0.01f, 8, 255); - Assert.AreEqual(8, result); + Assert.That(result, Is.EqualTo(8)); } /// @@ -75,7 +75,7 @@ [Test] public void CalcGainValue_比率が負_rangeMinを返す() { int result = GainCalc.CalcGainValue(100, -3.0f, 0, 255); - Assert.AreEqual(0, result); + Assert.That(result, Is.EqualTo(0)); } // --------------------------------------------------------------- @@ -92,7 +92,7 @@ [Test] public void CalcGainValue_比率がInfinity_rangeMaxを返す() { int result = GainCalc.CalcGainValue(133, float.PositiveInfinity, 0, 255); - Assert.AreEqual(255, result); + Assert.That(result, Is.EqualTo(255)); } /// @@ -101,7 +101,7 @@ [Test] public void CalcGainValue_比率が負のInfinity_rangeMinを返す() { int result = GainCalc.CalcGainValue(133, float.NegativeInfinity, 0, 255); - Assert.AreEqual(0, result); + Assert.That(result, Is.EqualTo(0)); } /// @@ -112,7 +112,7 @@ [Test] public void CalcGainValue_比率がNaN_現在値を維持する() { int result = GainCalc.CalcGainValue(133, float.NaN, 0, 255); - Assert.AreEqual(133, result); + Assert.That(result, Is.EqualTo(133)); } // --------------------------------------------------------------- diff --git a/TIASshot.Tests/GainCalcTests.cs b/TIASshot.Tests/GainCalcTests.cs index 121ecaf..f1ea45f 100644 --- a/TIASshot.Tests/GainCalcTests.cs +++ b/TIASshot.Tests/GainCalcTests.cs @@ -18,7 +18,7 @@ [Test] public void CalcRatio_通常ケースupdateRate0_5_正しい比率を返す() { float result = GainCalc.CalcRatio(100.0f, 200.0f, 0.5f); - Assert.AreEqual(1.5f, result, 1e-6f); + Assert.That(result, Is.EqualTo(1.5f).Within(1e-6f)); } // --------------------------------------------------------------- @@ -32,7 +32,7 @@ [Test] public void CalcRatio_updateRate1_0_係数1倍の比率を返す() { float result = GainCalc.CalcRatio(100.0f, 200.0f, 1.0f); - Assert.AreEqual(2.0f, result, 1e-6f); + Assert.That(result, Is.EqualTo(2.0f).Within(1e-6f)); } /// @@ -42,7 +42,7 @@ [Test] public void CalcRatio_updateRate0_1_updateRateが小さいほど変化量が小さい() { float result = GainCalc.CalcRatio(100.0f, 200.0f, 0.1f); - Assert.AreEqual(1.1f, result, 1e-6f); + Assert.That(result, Is.EqualTo(1.1f).Within(1e-6f)); } // --------------------------------------------------------------- @@ -56,7 +56,7 @@ [Test] public void CalcRatio_valueがtargetと等しい_updateRate0_5_比率は1() { float result = GainCalc.CalcRatio(150.0f, 150.0f, 0.5f); - Assert.AreEqual(1.0f, result, 1e-6f); + Assert.That(result, Is.EqualTo(1.0f).Within(1e-6f)); } /// @@ -68,7 +68,7 @@ public void CalcRatio_valueがtargetと等しい_updateRateを変えても結果が同じ() { float result05 = GainCalc.CalcRatio(150.0f, 150.0f, 0.5f); float result10 = GainCalc.CalcRatio(150.0f, 150.0f, 1.0f); - Assert.AreEqual(result05, result10, 1e-6f, "value==target のとき updateRate によらず結果が同じ"); + Assert.That(result10, Is.EqualTo(result05).Within(1e-6f), "value==target のとき updateRate によらず結果が同じ"); } // --------------------------------------------------------------- @@ -84,7 +84,7 @@ public void CalcRatio_value255_updateRate0_5_係数は常に1_0になる() { float expected = 200.0f / 255.0f; float result = GainCalc.CalcRatio(255.0f, 200.0f, 0.5f); - Assert.AreEqual(expected, result, 1e-6f); + Assert.That(result, Is.EqualTo(expected).Within(1e-6f)); } /// @@ -95,7 +95,7 @@ public void CalcRatio_value255_updateRateを変えても結果が同じ() { float result05 = GainCalc.CalcRatio(255.0f, 200.0f, 0.5f); float result10 = GainCalc.CalcRatio(255.0f, 200.0f, 1.0f); - Assert.AreEqual(result05, result10, 1e-6f, "value==255 のとき updateRate によらず結果が同じ"); + Assert.That(result10, Is.EqualTo(result05).Within(1e-6f), "value==255 のとき updateRate によらず結果が同じ"); } // --------------------------------------------------------------- @@ -110,7 +110,7 @@ [Test] public void CalcRatio_targetがvalueより小さい_updateRate0_5_比率が1未満になる() { float result = GainCalc.CalcRatio(200.0f, 100.0f, 0.5f); - Assert.AreEqual(0.75f, result, 1e-6f); + Assert.That(result, Is.EqualTo(0.75f).Within(1e-6f)); } // --------------------------------------------------------------- @@ -125,7 +125,7 @@ [Test] public void CalcRatio_updateRate0_比率は常に1() { float result = GainCalc.CalcRatio(100.0f, 200.0f, 0.0f); - Assert.AreEqual(1.0f, result, 1e-6f); + Assert.That(result, Is.EqualTo(1.0f).Within(1e-6f)); } // --------------------------------------------------------------- @@ -140,7 +140,7 @@ public void CalcRatio_value0_戻り値はInfinityまたはNaN_例外は出ない() { float result = GainCalc.CalcRatio(0.0f, 200.0f, 0.5f); // float(0 除算) は例外を投げずに Infinity / NaN を返す - Assert.IsTrue(float.IsInfinity(result) || float.IsNaN(result), + Assert.That(float.IsInfinity(result) || float.IsNaN(result), Is.True, "value=0 のとき戻り値は Infinity または NaN になる(例外は出ない)"); } } diff --git a/TIASshot.Tests/ImageStatsTests.cs b/TIASshot.Tests/ImageStatsTests.cs index 4d9b0cf..8032426 100644 --- a/TIASshot.Tests/ImageStatsTests.cs +++ b/TIASshot.Tests/ImageStatsTests.cs @@ -33,12 +33,12 @@ var stats = ImageStats.Calc(mat); // Assert: ch別 mean / max - Assert.AreEqual(100.0, stats.MeanB, 1e-3, "MeanB = 100"); - Assert.AreEqual(100.0, stats.MeanG, 1e-3, "MeanG = 100"); - Assert.AreEqual(100.0, stats.MeanR, 1e-3, "MeanR = 100"); - Assert.AreEqual(100.0, stats.MaxB, 1e-3, "MaxB = 100"); - Assert.AreEqual(100.0, stats.MaxG, 1e-3, "MaxG = 100"); - Assert.AreEqual(100.0, stats.MaxR, 1e-3, "MaxR = 100"); + Assert.That(stats.MeanB, Is.EqualTo(100.0).Within(1e-3), "MeanB = 100"); + Assert.That(stats.MeanG, Is.EqualTo(100.0).Within(1e-3), "MeanG = 100"); + Assert.That(stats.MeanR, Is.EqualTo(100.0).Within(1e-3), "MeanR = 100"); + Assert.That(stats.MaxB, Is.EqualTo(100.0).Within(1e-3), "MaxB = 100"); + Assert.That(stats.MaxG, Is.EqualTo(100.0).Within(1e-3), "MaxG = 100"); + Assert.That(stats.MaxR, Is.EqualTo(100.0).Within(1e-3), "MaxR = 100"); } } @@ -54,12 +54,12 @@ var stats = ImageStats.Calc(mat); // Assert - Assert.AreEqual(50.0, stats.MeanB, 1e-3, "MeanB = 50"); - Assert.AreEqual(120.0, stats.MeanG, 1e-3, "MeanG = 120"); - Assert.AreEqual(200.0, stats.MeanR, 1e-3, "MeanR = 200"); - Assert.AreEqual(50.0, stats.MaxB, 1e-3, "MaxB = 50"); - Assert.AreEqual(120.0, stats.MaxG, 1e-3, "MaxG = 120"); - Assert.AreEqual(200.0, stats.MaxR, 1e-3, "MaxR = 200"); + Assert.That(stats.MeanB, Is.EqualTo(50.0).Within(1e-3), "MeanB = 50"); + Assert.That(stats.MeanG, Is.EqualTo(120.0).Within(1e-3), "MeanG = 120"); + Assert.That(stats.MeanR, Is.EqualTo(200.0).Within(1e-3), "MeanR = 200"); + Assert.That(stats.MaxB, Is.EqualTo(50.0).Within(1e-3), "MaxB = 50"); + Assert.That(stats.MaxG, Is.EqualTo(120.0).Within(1e-3), "MaxG = 120"); + Assert.That(stats.MaxR, Is.EqualTo(200.0).Within(1e-3), "MaxR = 200"); } } @@ -79,14 +79,14 @@ var stats = ImageStats.Calc(mat); // Assert: MaxB=50, MaxG=60, MaxR=70(2画素の最大値) - Assert.AreEqual(50.0, stats.MaxB, 1e-3, "MaxB は 2 画素の最大値 50"); - Assert.AreEqual(60.0, stats.MaxG, 1e-3, "MaxG は 2 画素の最大値 60"); - Assert.AreEqual(70.0, stats.MaxR, 1e-3, "MaxR は 2 画素の最大値 70"); + Assert.That(stats.MaxB, Is.EqualTo(50.0).Within(1e-3), "MaxB は 2 画素の最大値 50"); + Assert.That(stats.MaxG, Is.EqualTo(60.0).Within(1e-3), "MaxG は 2 画素の最大値 60"); + Assert.That(stats.MaxR, Is.EqualTo(70.0).Within(1e-3), "MaxR は 2 画素の最大値 70"); // Assert: MeanB=(10+50)/2=30, MeanG=(20+60)/2=40, MeanR=(30+70)/2=50 - Assert.AreEqual(30.0, stats.MeanB, 1e-3, "MeanB = (10+50)/2 = 30"); - Assert.AreEqual(40.0, stats.MeanG, 1e-3, "MeanG = (20+60)/2 = 40"); - Assert.AreEqual(50.0, stats.MeanR, 1e-3, "MeanR = (30+70)/2 = 50"); + Assert.That(stats.MeanB, Is.EqualTo(30.0).Within(1e-3), "MeanB = (10+50)/2 = 30"); + Assert.That(stats.MeanG, Is.EqualTo(40.0).Within(1e-3), "MeanG = (20+60)/2 = 40"); + Assert.That(stats.MeanR, Is.EqualTo(50.0).Within(1e-3), "MeanR = (30+70)/2 = 50"); } } @@ -102,7 +102,7 @@ public void Calc_全画素同一値100_飽和率0() { using (var mat = new Mat(2, 2, MatType.CV_8UC3, new Scalar(100, 100, 100))) { var stats = ImageStats.Calc(mat); - Assert.AreEqual(0.0, stats.SaturatedRatio, 1e-6, "全輝度 100 → 飽和率 0"); + Assert.That(stats.SaturatedRatio, Is.EqualTo(0.0).Within(1e-6), "全輝度 100 → 飽和率 0"); } } @@ -114,7 +114,7 @@ public void Calc_全画素255_飽和率1_0() { using (var mat = new Mat(2, 2, MatType.CV_8UC3, new Scalar(255, 255, 255))) { var stats = ImageStats.Calc(mat); - Assert.AreEqual(1.0, stats.SaturatedRatio, 1e-6, "全画素 255 → 飽和率 1.0"); + Assert.That(stats.SaturatedRatio, Is.EqualTo(1.0).Within(1e-6), "全画素 255 → 飽和率 1.0"); } } @@ -126,7 +126,7 @@ public void Calc_全画素255_p99は255() { using (var mat = new Mat(2, 2, MatType.CV_8UC3, new Scalar(255, 255, 255))) { var stats = ImageStats.Calc(mat); - Assert.AreEqual(255.0, stats.P99, 1e-3, "全画素 255 → p99 = 255"); + Assert.That(stats.P99, Is.EqualTo(255.0).Within(1e-3), "全画素 255 → p99 = 255"); } } @@ -138,7 +138,7 @@ public void Calc_全画素同一値100_p99は100() { using (var mat = new Mat(2, 2, MatType.CV_8UC3, new Scalar(100, 100, 100))) { var stats = ImageStats.Calc(mat); - Assert.AreEqual(100.0, stats.P99, 1e-3, "全輝度 100 → p99 = 100"); + Assert.That(stats.P99, Is.EqualTo(100.0).Within(1e-3), "全輝度 100 → p99 = 100"); } } @@ -160,7 +160,7 @@ var stats = ImageStats.Calc(mat); // Assert: 4画素中1画素飽和 → 1/4 = 0.25 - Assert.AreEqual(0.25, stats.SaturatedRatio, 1e-6, "4画素中1画素飽和 → SaturatedRatio = 0.25"); + Assert.That(stats.SaturatedRatio, Is.EqualTo(0.25).Within(1e-6), "4画素中1画素飽和 → SaturatedRatio = 0.25"); } } @@ -177,7 +177,7 @@ var stats = ImageStats.Calc(mat); // Assert: 輝度ベースでは飽和なし - Assert.AreEqual(0.0, stats.SaturatedRatio, 1e-6, + Assert.That(stats.SaturatedRatio, Is.EqualTo(0.0).Within(1e-6), "R=255のみ → BGR2GRAY ≒ 76 < 254 → 輝度ベース飽和率 = 0"); } } @@ -190,7 +190,7 @@ public void Calc_G255のみ_輝度ベースでは飽和しない() { using (var mat = new Mat(1, 1, MatType.CV_8UC3, new Scalar(0, 255, 0))) { var stats = ImageStats.Calc(mat); - Assert.AreEqual(0.0, stats.SaturatedRatio, 1e-6, + Assert.That(stats.SaturatedRatio, Is.EqualTo(0.0).Within(1e-6), "G=255のみ → BGR2GRAY ≒ 150 < 254 → 飽和率 = 0"); } } @@ -207,7 +207,7 @@ public void CalcPercentile_全画素同一値150_p50は150() { using (var gray = new Mat(4, 4, MatType.CV_8U, new Scalar(150))) { double p50 = ImageStats.CalcPercentile(gray, 50.0); - Assert.AreEqual(150.0, p50, 1e-3, "全画素 150 → p50 = 150"); + Assert.That(p50, Is.EqualTo(150.0).Within(1e-3), "全画素 150 → p50 = 150"); } } @@ -219,7 +219,7 @@ public void CalcPercentile_全画素同一値200_p99は200() { using (var gray = new Mat(4, 4, MatType.CV_8U, new Scalar(200))) { double p99 = ImageStats.CalcPercentile(gray, 99.0); - Assert.AreEqual(200.0, p99, 1e-3, "全画素 200 → p99 = 200"); + Assert.That(p99, Is.EqualTo(200.0).Within(1e-3), "全画素 200 → p99 = 200"); } } @@ -231,7 +231,7 @@ public void CalcPercentile_全画素同一値100_p100は100() { using (var gray = new Mat(4, 4, MatType.CV_8U, new Scalar(100))) { double p100 = ImageStats.CalcPercentile(gray, 100.0); - Assert.AreEqual(100.0, p100, 1e-3, "全画素 100 → p100 = 100"); + Assert.That(p100, Is.EqualTo(100.0).Within(1e-3), "全画素 100 → p100 = 100"); } } @@ -251,7 +251,7 @@ double p50 = ImageStats.CalcPercentile(gray, 50.0); // Assert: threshold = 2 * 0.5 = 1.0。bin[100]=1.0 → cumulative=1.0 >= 1.0 → 100 - Assert.AreEqual(100.0, p50, 1e-3, "p50: bin[100]=1 → cumulative=1 >= threshold=1 → p50=100"); + Assert.That(p50, Is.EqualTo(100.0).Within(1e-3), "p50: bin[100]=1 → cumulative=1 >= threshold=1 → p50=100"); } } @@ -267,7 +267,7 @@ gray.At(0, 1) = 200; double p99 = ImageStats.CalcPercentile(gray, 99.0); - Assert.AreEqual(200.0, p99, 1e-3, "p99=200(上位1%に 200 が含まれるため)"); + Assert.That(p99, Is.EqualTo(200.0).Within(1e-3), "p99=200(上位1%に 200 が含まれるため)"); } } @@ -284,7 +284,7 @@ // Arrange: 0行0列の CV_8U using (var gray = new Mat(0, 0, MatType.CV_8U)) { double result = ImageStats.CalcPercentile(gray, 99.0); - Assert.AreEqual(0.0, result, 1e-6, "空 Mat → 0.0 を返す"); + Assert.That(result, Is.EqualTo(0.0).Within(1e-6), "空 Mat → 0.0 を返す"); } } @@ -310,12 +310,12 @@ string json = ImageStats.BuildJson(meta, null); // Assert: null / 空でない - Assert.IsNotNull(json, "json が null であってはならない"); - Assert.IsNotEmpty(json, "json が空文字列であってはならない"); + Assert.That(json, Is.Not.Null, "json が null であってはならない"); + Assert.That(json, Is.Not.Empty, "json が空文字列であってはならない"); // Assert: '{' / '}' で囲まれている(最低限の JSON 外枠) - Assert.IsTrue(json.TrimStart().StartsWith("{"), "JSON は '{' で始まる"); - Assert.IsTrue(json.TrimEnd().EndsWith("}"), "JSON は '}' で終わる"); + Assert.That(json.TrimStart().StartsWith("{"), Is.True, "JSON は '{' で始まる"); + Assert.That(json.TrimEnd().EndsWith("}"), Is.True, "JSON は '}' で終わる"); } /// @@ -332,8 +332,8 @@ string json = ImageStats.BuildJson(meta, null); // bool は JSON の true / false リテラルになること - StringAssert.Contains("\"flag_on\": true", json, "true が literal true になること"); - StringAssert.Contains("\"flag_off\": false", json, "false が literal false になること"); + Assert.That(json, Does.Contain("\"flag_on\": true"), "true が literal true になること"); + Assert.That(json, Does.Contain("\"flag_off\": false"), "false が literal false になること"); } /// @@ -348,7 +348,7 @@ string json = ImageStats.BuildJson(meta, null); // int は引用符なしの数値リテラル - StringAssert.Contains("\"num_images\": 10", json, "int は数値リテラルになること"); + Assert.That(json, Does.Contain("\"num_images\": 10"), "int は数値リテラルになること"); } /// @@ -364,9 +364,9 @@ string json = ImageStats.BuildJson(meta, null); // G6 + InvariantCulture: 1.23456789 → "1.23457"(四捨五入6桁) - StringAssert.Contains("\"val\": 1.23457", json, "double は G6 InvariantCulture で書式化される"); + Assert.That(json, Does.Contain("\"val\": 1.23457"), "double は G6 InvariantCulture で書式化される"); // ロケール依存のカンマ区切り(例: "1,23457")が混入していないこと - Assert.IsFalse(json.Contains("1,23457"), "小数点はカンマではなくピリオドを使う(InvariantCulture)"); + Assert.That(json.Contains("1,23457"), Is.False, "小数点はカンマではなくピリオドを使う(InvariantCulture)"); } /// @@ -382,7 +382,7 @@ string json = ImageStats.BuildJson(meta, null); // '"' が '\\"' にエスケープされた文字列として含まれること - StringAssert.Contains("say \\\"hello\\\"", json, "\" は \\\" にエスケープされること"); + Assert.That(json, Does.Contain("say \\\"hello\\\""), "\" は \\\" にエスケープされること"); } /// @@ -398,7 +398,7 @@ string json = ImageStats.BuildJson(meta, null); // '\' が '\\' にエスケープされた文字列として含まれること - StringAssert.Contains(@"C:\\TIAS_Data", json, @"\ は \\ にエスケープされること"); + Assert.That(json, Does.Contain(@"C:\\TIAS_Data"), @"\ は \\ にエスケープされること"); } /// @@ -427,18 +427,18 @@ string json = ImageStats.BuildJson(meta, stats); // Assert: "frame_stats" 配列が存在する - StringAssert.Contains("\"frame_stats\"", json, "frame_stats キーが存在すること"); + Assert.That(json, Does.Contain("\"frame_stats\""), "frame_stats キーが存在すること"); // Assert: フレーム番号・各フィールドが含まれる - StringAssert.Contains("\"frame\": 1", json, "frame 番号 1 が含まれること"); - StringAssert.Contains("\"saturated_ratio\"", json, "saturated_ratio キーが含まれること"); - StringAssert.Contains("\"p99_luminance\"", json, "p99_luminance キーが含まれること"); - StringAssert.Contains("\"mean_B\"", json, "mean_B キーが含まれること"); - StringAssert.Contains("\"mean_G\"", json, "mean_G キーが含まれること"); - StringAssert.Contains("\"mean_R\"", json, "mean_R キーが含まれること"); - StringAssert.Contains("\"max_B\"", json, "max_B キーが含まれること"); - StringAssert.Contains("\"max_G\"", json, "max_G キーが含まれること"); - StringAssert.Contains("\"max_R\"", json, "max_R キーが含まれること"); + Assert.That(json, Does.Contain("\"frame\": 1"), "frame 番号 1 が含まれること"); + Assert.That(json, Does.Contain("\"saturated_ratio\""), "saturated_ratio キーが含まれること"); + Assert.That(json, Does.Contain("\"p99_luminance\""), "p99_luminance キーが含まれること"); + Assert.That(json, Does.Contain("\"mean_B\""), "mean_B キーが含まれること"); + Assert.That(json, Does.Contain("\"mean_G\""), "mean_G キーが含まれること"); + Assert.That(json, Does.Contain("\"mean_R\""), "mean_R キーが含まれること"); + Assert.That(json, Does.Contain("\"max_B\""), "max_B キーが含まれること"); + Assert.That(json, Does.Contain("\"max_G\""), "max_G キーが含まれること"); + Assert.That(json, Does.Contain("\"max_R\""), "max_R キーが含まれること"); } /// @@ -454,8 +454,8 @@ string json = ImageStats.BuildJson(meta, stats); - StringAssert.Contains("\"frame\": 1", json, "1枚目のフレーム番号は 1"); - StringAssert.Contains("\"frame\": 2", json, "2枚目のフレーム番号は 2"); + Assert.That(json, Does.Contain("\"frame\": 1"), "1枚目のフレーム番号は 1"); + Assert.That(json, Does.Contain("\"frame\": 2"), "2枚目のフレーム番号は 2"); } /// @@ -467,7 +467,7 @@ string json = ImageStats.BuildJson(meta, null); - Assert.IsFalse(json.Contains("frame_stats"), + Assert.That(json.Contains("frame_stats"), Is.False, "stats が null のとき frame_stats キーは生成されない"); } @@ -480,7 +480,7 @@ string json = ImageStats.BuildJson(meta, new List()); - Assert.IsFalse(json.Contains("frame_stats"), + Assert.That(json.Contains("frame_stats"), Is.False, "stats が空リストのとき frame_stats キーは生成されない"); } @@ -498,7 +498,7 @@ string json = ImageStats.BuildJson(meta, stats); // G6 で 0.0 → "0" - StringAssert.Contains("\"saturated_ratio\": 0", json, + Assert.That(json, Does.Contain("\"saturated_ratio\": 0"), "飽和率 0 は JSON に 0 としてシリアライズされること"); } @@ -512,7 +512,7 @@ [Test] public void Calc_null_ArgumentNullExceptionをスローする() { Assert.Throws( - () => ImageStats.Calc(null), + (Action)(() => ImageStats.Calc(null)), "null Mat → ArgumentNullException"); } @@ -524,7 +524,7 @@ public void Calc_CV8UC1Mat_ArgumentExceptionをスローする() { using (var gray = new Mat(2, 2, MatType.CV_8U, new Scalar(100))) { Assert.Throws( - () => ImageStats.Calc(gray), + (Action)(() => ImageStats.Calc(gray)), "CV_8UC1 → ArgumentException(CV_8UC3 以外は不可)"); } } @@ -537,7 +537,7 @@ public void Calc_CV64FC3Mat_ArgumentExceptionをスローする() { using (var mat = new Mat(2, 2, MatType.CV_64FC3)) { Assert.Throws( - () => ImageStats.Calc(mat), + (Action)(() => ImageStats.Calc(mat)), "CV_64FC3 → ArgumentException(CV_8UC3 以外は不可)"); } } @@ -552,7 +552,7 @@ [Test] public void CalcPercentile_null_ArgumentNullExceptionをスローする() { Assert.Throws( - () => ImageStats.CalcPercentile(null, 99.0), + (Action)(() => ImageStats.CalcPercentile(null, 99.0)), "null Mat → ArgumentNullException"); } @@ -564,7 +564,7 @@ public void CalcPercentile_CV8UC3Mat_ArgumentExceptionをスローする() { using (var bgr = new Mat(2, 2, MatType.CV_8UC3, new Scalar(100, 100, 100))) { Assert.Throws( - () => ImageStats.CalcPercentile(bgr, 99.0), + (Action)(() => ImageStats.CalcPercentile(bgr, 99.0)), "CV_8UC3 → ArgumentException(CV_8U 以外は不可)"); } } diff --git a/TIASshot.Tests/IoUtilCombineSaveFilePathTests.cs b/TIASshot.Tests/IoUtilCombineSaveFilePathTests.cs index 39f93e6..f4b8564 100644 --- a/TIASshot.Tests/IoUtilCombineSaveFilePathTests.cs +++ b/TIASshot.Tests/IoUtilCombineSaveFilePathTests.cs @@ -38,7 +38,7 @@ var result = IoUtil.CombineSaveFilePath(_saveFolder, filename); // Assert - Assert.IsTrue(IsUnderSaveFolder(result), + Assert.That(IsUnderSaveFolder(result), Is.True, $"保存先が保存フォルダの外を指している: {Path.GetFullPath(result)}"); } @@ -54,7 +54,7 @@ var result = IoUtil.CombineSaveFilePath(_saveFolder, filename); // Assert - Assert.IsTrue(IsUnderSaveFolder(result), + Assert.That(IsUnderSaveFolder(result), Is.True, $"保存先が保存フォルダの外を指している: {Path.GetFullPath(result)}"); } @@ -78,7 +78,7 @@ var result = IoUtil.CombineSaveFilePath(_saveFolder, filename); // Assert - Assert.AreEqual(Path.Combine(_saveFolder, filename), result, + Assert.That(result, Is.EqualTo(Path.Combine(_saveFolder, filename)), "同梱 config.xml の既存値では結合結果が従来と完全一致すること"); } } diff --git a/TIASshot.Tests/IoUtilCreateSaveFolderTests.cs b/TIASshot.Tests/IoUtilCreateSaveFolderTests.cs index c0c1627..1a70c47 100644 --- a/TIASshot.Tests/IoUtilCreateSaveFolderTests.cs +++ b/TIASshot.Tests/IoUtilCreateSaveFolderTests.cs @@ -57,9 +57,9 @@ var result = IoUtil.CreateSaveFolder(note); // Assert - Assert.IsTrue(IsUnderBase(result), + Assert.That(IsUnderBase(result), Is.True, $"保存フォルダがベースフォルダの外に生成された: {Path.GetFullPath(result)}"); - Assert.IsFalse(Directory.Exists(Path.Combine(_rootDir, "evil")), + Assert.That(Directory.Exists(Path.Combine(_rootDir, "evil")), Is.False, "ベースフォルダ外に 'evil' ディレクトリが作られてはならない"); } @@ -73,13 +73,13 @@ // Act string result = null; - Assert.DoesNotThrow(() => { result = IoUtil.CreateSaveFolder(note); }, + Assert.DoesNotThrow((Action)(() => { result = IoUtil.CreateSaveFolder(note); }), "不正文字を含むデータ名で例外が発生してはならない(撮影中断の可用性影響)"); // Assert - Assert.IsNotNull(result); - Assert.IsTrue(Directory.Exists(result), "保存フォルダが生成されていること"); - Assert.IsTrue(IsUnderBase(result), "保存フォルダはベースフォルダ配下であること"); + Assert.That(result, Is.Not.Null); + Assert.That(Directory.Exists(result), Is.True, "保存フォルダが生成されていること"); + Assert.That(IsUnderBase(result), Is.True, "保存フォルダはベースフォルダ配下であること"); } /// @@ -94,9 +94,9 @@ var result = IoUtil.CreateSaveFolder(note); // Assert - Assert.IsTrue(Directory.Exists(result), "保存フォルダが生成されていること"); - Assert.IsTrue(IsUnderBase(result), "保存フォルダはベースフォルダ配下であること"); - StringAssert.EndsWith("-subject01", Path.GetFileName(result), + Assert.That(Directory.Exists(result), Is.True, "保存フォルダが生成されていること"); + Assert.That(IsUnderBase(result), Is.True, "保存フォルダはベースフォルダ配下であること"); + Assert.That(Path.GetFileName(result), Does.EndWith("-subject01"), "フォルダ名末尾にデータ名がそのまま保持されること"); } } diff --git a/TIASshot.Tests/IoUtilSaveImageTests.cs b/TIASshot.Tests/IoUtilSaveImageTests.cs index 4103e30..4c56a45 100644 --- a/TIASshot.Tests/IoUtilSaveImageTests.cs +++ b/TIASshot.Tests/IoUtilSaveImageTests.cs @@ -42,8 +42,8 @@ var result = IoUtil.SaveImage(path, img); // Assert - Assert.IsFalse(result, "存在しないフォルダへの保存失敗が false で報告されること"); - Assert.IsFalse(File.Exists(path), "ファイルが生成されていないこと"); + Assert.That(result, Is.False, "存在しないフォルダへの保存失敗が false で報告されること"); + Assert.That(File.Exists(path), Is.False, "ファイルが生成されていないこと"); } } @@ -60,8 +60,8 @@ var result = IoUtil.SaveImage(path, img); // Assert - Assert.IsTrue(result, "正常な保存では true を返すこと"); - Assert.IsTrue(File.Exists(path), "ファイルが生成されていること"); + Assert.That(result, Is.True, "正常な保存では true を返すこと"); + Assert.That(File.Exists(path), Is.True, "ファイルが生成されていること"); } } } diff --git a/TIASshot.Tests/IoUtilTests.cs b/TIASshot.Tests/IoUtilTests.cs index d770ef5..2a290f3 100644 --- a/TIASshot.Tests/IoUtilTests.cs +++ b/TIASshot.Tests/IoUtilTests.cs @@ -32,16 +32,15 @@ IoUtil.SaveMatToCsv(_csvPath, src); using (var loaded = IoUtil.LoadMatFromCsv(_csvPath)) { // Assert - Assert.IsNotNull(loaded, "ロード結果が null であってはならない"); - Assert.AreEqual(src.Rows, loaded.Rows, "行数が一致すること"); - Assert.AreEqual(src.Cols, loaded.Cols, "列数が一致すること"); + Assert.That(loaded, Is.Not.Null, "ロード結果が null であってはならない"); + Assert.That(loaded.Rows, Is.EqualTo(src.Rows), "行数が一致すること"); + Assert.That(loaded.Cols, Is.EqualTo(src.Cols), "列数が一致すること"); for (int row = 0; row < src.Rows; row++) { for (int col = 0; col < src.Cols; col++) { - Assert.AreEqual( - src.At(row, col), + Assert.That( loaded.At(row, col), - 1e-6, + Is.EqualTo(src.At(row, col)).Within(1e-6), $"[{row},{col}] の値が ±1e-6 以内で一致すること"); } } @@ -63,8 +62,8 @@ IoUtil.SaveMatToCsv(_csvPath, src); using (var loaded = IoUtil.LoadMatFromCsv(_csvPath)) { // Assert - Assert.IsNotNull(loaded); - Assert.AreEqual(Pi, loaded.At(0, 0), 1e-6); + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.At(0, 0), Is.EqualTo(Pi).Within(1e-6)); } } } @@ -87,10 +86,10 @@ // Act using (var loaded = IoUtil.LoadMatFromCsv(_csvPath)) { // Assert - Assert.IsNotNull(loaded); - Assert.AreEqual(96.32301186, loaded.At(0, 0), 1e-6); - Assert.AreEqual(112.325025, loaded.At(0, 1), 1e-6); - Assert.AreEqual(186.5234201, loaded.At(0, 2), 1e-6); + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded.At(0, 0), Is.EqualTo(96.32301186).Within(1e-6)); + Assert.That(loaded.At(0, 1), Is.EqualTo(112.325025).Within(1e-6)); + Assert.That(loaded.At(0, 2), Is.EqualTo(186.5234201).Within(1e-6)); } } finally { System.Threading.Thread.CurrentThread.CurrentCulture = original; @@ -118,7 +117,7 @@ // Assert var line = File.ReadAllLines(_csvPath)[0]; - Assert.AreEqual("1.2345678,-2.5000000", line, + Assert.That(line, Is.EqualTo("1.2345678,-2.5000000"), "ロケールによらずピリオド小数・カンマ列区切りで出力すること"); } } finally { @@ -144,8 +143,8 @@ // Assert var lines = File.ReadAllLines(_csvPath); - Assert.AreEqual("1.2345670,-2.3456780,0.0000010", lines[0]); - Assert.AreEqual("100.0000000,-0.5000000,999.9999990", lines[1]); + Assert.That(lines[0], Is.EqualTo("1.2345670,-2.3456780,0.0000010")); + Assert.That(lines[1], Is.EqualTo("100.0000000,-0.5000000,999.9999990")); } } finally { System.Threading.Thread.CurrentThread.CurrentCulture = original; @@ -163,16 +162,16 @@ "..", "..", "..", "..", "TIASshot")); var srgbPath = Path.Combine(repoDir, "tcc_srgb.csv"); var xyzPath = Path.Combine(repoDir, "tcc_xyz.csv"); - Assert.IsTrue(File.Exists(srgbPath), $"同梱 tcc_srgb.csv が見つからない: {srgbPath}"); - Assert.IsTrue(File.Exists(xyzPath), $"同梱 tcc_xyz.csv が見つからない: {xyzPath}"); + Assert.That(File.Exists(srgbPath), Is.True, $"同梱 tcc_srgb.csv が見つからない: {srgbPath}"); + Assert.That(File.Exists(xyzPath), Is.True, $"同梱 tcc_xyz.csv が見つからない: {xyzPath}"); using (var srgb = IoUtil.LoadMatFromCsv(srgbPath)) using (var xyz = IoUtil.LoadMatFromCsv(xyzPath)) { - Assert.IsNotNull(srgb, "tcc_srgb.csv が読めること"); - Assert.IsNotNull(xyz, "tcc_xyz.csv が読めること"); - Assert.AreEqual(3, srgb.Cols, "tcc_srgb.csv は 3 列であること"); - Assert.AreEqual(3, xyz.Cols, "tcc_xyz.csv は 3 列であること"); - Assert.AreEqual(96.32301186, srgb.At(0, 0), 1e-6, + Assert.That(srgb, Is.Not.Null, "tcc_srgb.csv が読めること"); + Assert.That(xyz, Is.Not.Null, "tcc_xyz.csv が読めること"); + Assert.That(srgb.Cols, Is.EqualTo(3), "tcc_srgb.csv は 3 列であること"); + Assert.That(xyz.Cols, Is.EqualTo(3), "tcc_xyz.csv は 3 列であること"); + Assert.That(srgb.At(0, 0), Is.EqualTo(96.32301186).Within(1e-6), "tcc_srgb.csv の先頭要素が従来値で読めること"); } } @@ -186,7 +185,7 @@ var result = IoUtil.LoadMatFromCsv("does_not_exist_xyz.csv"); // Assert - Assert.IsNull(result, "存在しないファイルのロードは null を返すこと"); + Assert.That(result, Is.Null, "存在しないファイルのロードは null を返すこと"); } // --------------------------------------------------------------- diff --git a/TIASshot.Tests/MemBenchSupportTests.cs b/TIASshot.Tests/MemBenchSupportTests.cs index ff12edc..22bade2 100644 --- a/TIASshot.Tests/MemBenchSupportTests.cs +++ b/TIASshot.Tests/MemBenchSupportTests.cs @@ -33,9 +33,9 @@ using (var src = new Mat(2, 2, MatType.CV_8UC3, new Scalar(50, 100, 150))) using (var dst = MemBenchSupport.ConvertImageLeaky(corrector, src, conv)) { // Assert: 型・サイズ - Assert.AreEqual(MatType.CV_8UC3, dst.Type(), "出力型は CV_8UC3"); - Assert.AreEqual(2, dst.Rows, "出力行数は入力と同じ(2)"); - Assert.AreEqual(2, dst.Cols, "出力列数は入力と同じ(2)"); + Assert.That(dst.Type(), Is.EqualTo(MatType.CV_8UC3), "出力型は CV_8UC3"); + Assert.That(dst.Rows, Is.EqualTo(2), "出力行数は入力と同じ(2)"); + Assert.That(dst.Cols, Is.EqualTo(2), "出力列数は入力と同じ(2)"); } } } @@ -57,9 +57,9 @@ for (int row = 0; row < dst.Rows; row++) { for (int col = 0; col < dst.Cols; col++) { var pixel = dst.At(row, col); - Assert.AreEqual(0, pixel.Item0, $"row={row}, col={col}: B は 0"); - Assert.AreEqual(0, pixel.Item1, $"row={row}, col={col}: G は 0"); - Assert.AreEqual(0, pixel.Item2, $"row={row}, col={col}: R は 0"); + Assert.That(pixel.Item0, Is.EqualTo(0), $"row={row}, col={col}: B は 0"); + Assert.That(pixel.Item1, Is.EqualTo(0), $"row={row}, col={col}: G は 0"); + Assert.That(pixel.Item2, Is.EqualTo(0), $"row={row}, col={col}: R は 0"); } } } diff --git a/TIASshot.Tests/SelfTestChartTests.cs b/TIASshot.Tests/SelfTestChartTests.cs new file mode 100644 index 0000000..a46dfa3 --- /dev/null +++ b/TIASshot.Tests/SelfTestChartTests.cs @@ -0,0 +1,303 @@ +#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(PredefinedDictionaryType.Dict4X4_50)) + using (var detector = new ArucoDetector(dict, new DetectorParameters(), new RefineParameters())) { + detector.DetectMarkers(frame, out var corners, out var ids, 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 f78955e..2ab809f 100644 --- a/TIASshot.Tests/TIASshot.Tests.csproj +++ b/TIASshot.Tests/TIASshot.Tests.csproj @@ -1,7 +1,10 @@ + + + + - Debug @@ -21,7 +24,8 @@ true bin\x64\Debug\ - DEBUG;TRACE + + DEBUG;TRACE;SELFTEST full x64 7.3 @@ -37,25 +41,37 @@ prompt - - ..\packages\NUnit.3.14.0\lib\net45\nunit.framework.dll + + ..\packages\Microsoft.CodeCoverage.18.10.1\lib\net462\Microsoft.VisualStudio.CodeCoverage.Shim.dll + True + + + ..\packages\NUnit.4.6.1\lib\net462\nunit.framework.dll ..\packages\OpenCvSharp4.4.13.0.20260627\lib\netstandard2.0\OpenCvSharp.dll - + ..\packages\System.Buffers.4.6.1\lib\net462\System.Buffers.dll + True - + ..\packages\System.Memory.4.6.3\lib\net462\System.Memory.dll + True - + ..\packages\System.Numerics.Vectors.4.6.1\lib\net462\System.Numerics.Vectors.dll + True - + ..\packages\System.Runtime.CompilerServices.Unsafe.6.1.2\lib\net462\System.Runtime.CompilerServices.Unsafe.dll + True + + + ..\packages\System.Threading.Tasks.Extensions.4.6.3\lib\net462\System.Threading.Tasks.Extensions.dll + True @@ -84,6 +100,9 @@ + + + @@ -95,7 +114,16 @@ - + + + + + + + + + + - + \ No newline at end of file diff --git a/TIASshot.Tests/packages.config b/TIASshot.Tests/packages.config index 112e7d6..9527e71 100644 --- a/TIASshot.Tests/packages.config +++ b/TIASshot.Tests/packages.config @@ -1,12 +1,15 @@ - - - + + + + - + + + \ No newline at end of file diff --git a/TIASshot/App.config b/TIASshot/App.config index f0f212b..f08cfd6 100644 --- a/TIASshot/App.config +++ b/TIASshot/App.config @@ -13,6 +13,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file 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..18721ea --- /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(PredefinedDictionaryType.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..39dbc15 --- /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(PredefinedDictionaryType.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 c7d2973..0ebe8b3 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 @@ -78,28 +78,30 @@ - + ..\packages\System.Buffers.4.6.1\lib\net462\System.Buffers.dll + True - - ..\packages\System.Drawing.Common.8.0.0\lib\net462\System.Drawing.Common.dll + + ..\packages\System.Drawing.Common.8.0.31\lib\net462\System.Drawing.Common.dll + True - + ..\packages\System.Memory.4.6.3\lib\net462\System.Memory.dll + True - + ..\packages\System.Numerics.Vectors.4.6.1\lib\net462\System.Numerics.Vectors.dll + True - + ..\packages\System.Runtime.CompilerServices.Unsafe.6.1.2\lib\net462\System.Runtime.CompilerServices.Unsafe.dll + True - - ..\packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll - @@ -140,6 +142,9 @@ Form1.cs + + Form1.cs + Form @@ -157,6 +162,13 @@ PreviewMonitor.cs + + PreviewMonitor.cs + + + + + @@ -201,6 +213,7 @@ + copy /Y $(ProjectDir)config.xml $(TargetDir) @@ -211,5 +224,6 @@ copy /Y $(ProjectDir)*.wav $(TargetDir) + - + \ No newline at end of file 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/TIASshot/packages.config b/TIASshot/packages.config index 86ea2ce..88b1e4e 100644 --- a/TIASshot/packages.config +++ b/TIASshot/packages.config @@ -6,9 +6,9 @@ - + - + \ No newline at end of file diff --git "a/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" "b/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" index 7ddca5d..574cdde 100644 --- "a/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" +++ "b/docs/01_GUIDE/GUIDE_01_\343\203\227\343\203\255\343\202\270\343\202\247\343\202\257\343\203\210\347\253\213\343\201\241\344\270\212\343\201\222\343\203\225\343\203\255\343\203\274.md" @@ -23,13 +23,21 @@ | レイヤ | 対象 | 存在するモード | | --- | --- | --- | -| 共通層 | `GUIDE_01`・`GUIDE_02`,`.claude/rules/` のすべて,`.claude/agents/`,`.claude/template-overrides.md`(雛形),skills(`commit`(+ `reference.md`)・`implement`・`setup`(+ `reference.md`)・`sync-template`(+ `reference.md`)・`set-mode`・`auto-refactor`・`auto-audit`・`deps-update`(+ `reference.md`)・`task-create`・`task-start`(+ `reference.md`)・`task-handoff`) | solo・team 両方 | +| 共通層 | `GUIDE_01`・`GUIDE_02`,`.claude/rules/` のすべて,`.claude/agents/`,`.claude/template-overrides.md`(雛形),skills(`commit`(+ `reference.md`)・`implement`・`setup`(+ `reference.md`)・`sync-template`(+ `reference.md`)・`set-mode`・`auto-refactor`・`auto-audit`・`deps-update`(+ `reference.md`)・`verify`(+ `profile-template.md`)・`task-create`・`task-start`(+ `reference.md`)・`task-handoff`) | solo・team 両方 | | team 層 | `GUIDE_03`,`.claude/hooks/check_sync.sh`,`settings.json` の SessionStart 配線 | team のみ | - `.claude/rules/` はすべて共通層だが,進捗記録ルール(`progress-log`)だけは team モードで運用上**上書き**される(進捗は `CLAUDE.md`/`docs/PROGRESS.md` ではなく GitHub Issues と git 履歴で追う.GUIDE_03). - team 層ファイルの配置・削除は `/set-mode` が一括で行い,`/sync-template` は `.claude/project-mode` を見て team 層の同期可否を判定する.team 層ファイルの正確なリストは `/set-mode`・`/sync-template` の定義に持たせており,増減時は両者を一致させること. - テンプレート由来のファイルをプロジェクトの都合で意図的に変更した場合は,`.claude/template-overrides.md`(テンプレート改変台帳)に方針(`keep` / `merge` / `ask`)と理由を記録する(`.claude/rules/template-customization.md`).`/sync-template` は台帳を読み,登録ファイルをテンプレート版で上書きせず方針に従って処理する.未登録でも前回同期版から改変されたファイルは上書き前に確認される. +## 立ち上げ中の Git 運用 (Git During Setup) + +立ち上げ作業は専用ブランチ `chore/project-setup` の上で行い,**各フェーズの成果物が確定(ユーザーが承認)した時点でコミットする**(フェーズごとに 1 コミット).`/setup` が Git 規約(`.claude/rules/git-conventions.md`)の書式でコミットまで行い,`git push`・PR 作成・マージ・`main` への直接コミットは行わない.手順とフェーズごとのコミットメッセージ例は `.claude/skills/setup/reference.md`「フェーズごとのコミット」にある. + +- 狙いは,立ち上げの成果物をフェーズ単位でレビュー・巻き戻しできる形にし,中断・再開しても「どこまで終わったか」が git 履歴から辿れるようにすること +- 全フェーズ完了後,`main` への取り込みはユーザーが `/commit push`(PR 作成まで)または `/commit merge`(マージまで)で行う +- これは CLAUDE.md「Git 運用」の「`/commit` 自発実行禁止」に対するユーザー承認済みの例外(`/auto-refactor`・`/auto-audit` と同格)であり,`chore/project-setup` ブランチへのコミットに限る + ## 方針決定 (Direction) プロジェクトの全体像を明確にする. @@ -53,18 +61,39 @@ - **前提条件**: 以下のツールは全プロジェクト共通で導入する. - `git` — バージョン管理(運用ルールは `.claude/rules/git-conventions.md` で定義) - `gh` — GitHub CLI(PR 作成・マージ等に使用) + - Python 3.7 以上と `bash` — `.claude/settings.json` のフック(リポジトリ外アクセス制限・通知)の実行に使用.フックは `bash .claude/hooks/run_python.sh` 経由で起動し,`python3` → `python` → `py` の順に実際に起動できるものを使う(いずれか 1 つで起動できればよい.Windows では Git for Windows 付属の `bash` を使う).※ Python が見つからないとアクセス制限フックが Read・Write・Edit・Glob・Grep・Bash を**すべてブロックする**(通知フックは何もせず素通りする) - **基本方針**: 環境の再現性を重視し,手順書だけに頼らず構築を自動化・コード化できる方法を優先する(例: Docker,Dev Containers,Windows Sandbox,IaC ツール等). - **人間が決めること**: 開発マシンの選定,クラウドサービスのアカウント作成,環境構築方法の選択 -- **AI に依頼できること**: 環境構築手順書の作成,設定ファイルの生成,`.gitignore` の作成,Dockerfile や devcontainer.json 等の構築用ファイルの作成,GitHub リポジトリのセキュリティ設定・`.github/dependabot.yml` の生成(後述) +- **実際の構築まで行う**: 手順書を書いて終わりにせず,`/setup` がその手順に従って実際に環境を構築し,実行して分かった誤り・前提の抜けを手順書に反映する(未検証の手順書は誤りを含みやすく,最初に詰まるのがその場のユーザーになるため).区分は次の 3 つ(詳細は `.claude/skills/setup/reference.md`「環境構築の実行」) + - リポジトリ内で完結する操作(プロジェクト初期化・依存インストール・設定ファイル生成・ビルドや起動の疎通確認・`gh` でのリポジトリ設定)はそのまま実行する + - ツール導入(SDK・ランタイム・CLI のインストール,`docker build`,dev container の起動)は実行するコマンド・バージョン・入る場所を提示して**都度承認を得てから**実行する + - 外部サービスのコンソール操作・アカウント作成・対話ログイン(`gh auth login` 等)・クレデンシャルの発行はユーザーが行う +- **AI に依頼できること**: 環境構築手順書の作成,設定ファイルの生成,`.gitignore` の作成,Dockerfile や devcontainer.json 等の構築用ファイルの作成,GitHub リポジトリのセキュリティ設定・`.github/dependabot.yml` の生成(後述),検証プロファイル(`.claude/verify-profile.md`.任意.後述)の記入 - **GitHub リポジトリのセキュリティ設定**: GitHub の Settings → Code security にある **Dependabot alerts**(既知脆弱性の検出."Vulnerabilities" として表示される)と **Dependabot security updates** はリポジトリごとに既定で OFF のため,リポジトリを作成したら必ず有効化する.モードに関わらず `/setup` が `gh api` で有効化・検証する(コマンド・検証・トラブル対応は `.claude/skills/setup/reference.md`「GitHub リポジトリのセキュリティ設定」).`/setup` 時点でリポジトリが無い場合は,`ENV_03_管理者用環境構築手順.md` に転記した同じコマンドをリポジトリ作成後に実行する. - **依存バージョン更新(Dependabot version updates)**: 上記とは別に,依存パッケージを定期的に最新化する PR を作らせるため,`/setup` が技術スタックに合わせて `.github/dependabot.yml` を必ず生成する(週 1 回・マイナー/パッチをまとめる・`[update]` プレフィックス.エコシステム対応表と雛形は `.claude/skills/setup/reference.md`「依存バージョン更新の設定」).Dependabot が作る PR と alert の処理は `/deps-update` で行う(ゲートを満たす PR を自動マージし,メジャー更新等は分析付きで報告.GUIDE_02「コミットルール」の例外). - **Claude Code の権限設定(`.claude/settings.json`)**: テンプレート同梱の `permissions.allow`(`Bash(gh pr merge:*)`)は,auto mode で ops-runner に `/commit merge`・`/deps-update` のマージを実行させるために必要なので削除しない(変更はセッション再起動後に反映).マージが分類器に止められた場合の切り分けと対処は `.claude/skills/commit/reference.md`「gh コマンドが実行される前にブロックされた場合」を参照. +- **スマホへのプッシュ通知(任意)**: テンプレート同梱の `.claude/settings.json` は,作業完了(`Stop`)・許可待ち(`Notification`)・質問(`PreToolUse` の `AskUserQuestion`)で `.claude/hooks/notify.py` を呼び,[ntfy](https://ntfy.sh) 経由でスマホに通知する(ホスト OS・dev container のどちらでも動く).送信先トピックが未設定なら何もしないため,受け取りたい人だけ以下を行う. + - スマホに ntfy アプリを入れ,推測されにくいトピック名(例: `claude-` +ランダムな英数字)を購読する.トピック名を知っていれば誰でも購読・送信できるため,パスワードと同様に扱いリポジトリには書かない + - iPhone でアプリを開くと届いているのにプッシュ通知が出ない場合は,トピックを削除してアプリを再起動し,購読し直す(ntfy の既知の問題.購読し直すと通知の登録がやり直される) + - `{ "env": { "NTFY_TOPIC": "<トピック名>" } }` を以下のいずれかに書く(既存の設定がある場合は `env` にキーを追加する.反映されない場合はセッションを再起動する) + - **dev container を使わない場合**: ユーザー設定 `~/.claude/settings.json` に書く.デバイスごとに 1 回でよく,全プロジェクトで共通のトピックに通知される(フックの無いプロジェクトでは何も起きない).プロジェクトフォルダがクラウド同期(OneDrive 等)されていてもトピック名が同期されない + - **dev container を使う場合**: プロジェクトの `.claude/settings.local.json`(gitignore 済み)に書く.ホストの `~/.claude` は通常コンテナから見えないが,ワークスペースはコンテナに共有されるため,プロジェクトごとに 1 回でよい + - 両方に書いた場合はプロジェクトの `.claude/settings.local.json` が優先される + - 外部サービスを経由するため,通知には「Claude Code (リポジトリ名)」(origin リモートが無ければフォルダ名)と定型文だけを送り,コードや会話の内容は送らない + - 完了通知は**ターン終了のたびには送らない**.サブエージェント(`/implement` の coder 等)やワークフローに依頼して待っているだけのターン終了では黙り,依頼した作業が終わって本当に手が空いたときだけ「処理が完了しました」を送る(フックが `Stop` の入力 `background_tasks` を見て判定する).一方,バックグラウンドのシェル(開発サーバー等)は動かし続けるのが普通で,対象にすると通知が永久に止まるため判定から除いている + - 許可待ち・質問の通知は,サブエージェント起因なら本文に「(エージェント: coder)」のようにエージェント名が付く - **成果物**: - `ENV_02_環境構築手順.md` — メンバーの参加時や環境の再構築時に使う手順 - `ENV_03_管理者用環境構築手順.md` — プロジェクト作成時に一度だけ行う初期設定(リポジトリ作成,GitHub リポジトリのセキュリティ設定,外部サービスの設定等) - `ENV_04_開発コマンド.md`(任意)— アプリの起動・テスト・lint・ビルド等,日常の開発で使うコマンド一覧.人間とエージェント(tester・coder,`/auto-refactor`・`/auto-audit`)がコマンドを推測せずに済むようにするための唯一の参照先とする - 立ち上げ時点で確定しているコマンドが無ければ**作成を後回しにしてよい**.実装中にコマンドが確定・変更された時点で作成・追記する(`/implement` の Phase 4 で自動的に見直される) - コマンドが増えてきたら,タスクランナー(npm scripts・Makefile 等)への集約を優先し,本ファイルは薄い一覧に保つ(「手順書より自動化・コード化」の基本方針と同じ) + - `.claude/verify-profile.md`(任意)— **検証プロファイル**.Claude(`verifier` エージェント)がアプリを操作して動作確認できるようにするための,プロジェクト固有の情報(検証手段・**検証対象の環境**・実行コマンドと出力先・前提条件・認証/ロール・テストデータの規約・既知のノイズ・禁止操作・シナリオのテンプレート)をまとめたファイル + - 用意すると `/verify` と `/implement` の Phase 1b が動き,人間の動作確認の前段としてエッジケース等を Claude が先に潰す(人間の確認は無くならない.GUIDE_02).用意しなければ Phase 1b はスキップされ,従来どおり人間が動作確認する + - 雛形 `.claude/skills/verify/profile-template.md` を `.claude/verify-profile.md` にコピーして記入する.シナリオを実行するランナー(起動・実行・集計・後始末)はプロジェクト側に用意する(要件は雛形の「実行基盤の要件」,Web アプリ向けの実装例は同「参考実装」のリンク先) + - 検証対象の環境は**既定でローカルのみ**.staging 等の検証用環境を使わせるかは人間が判断し,使わせる場合だけ「いじっても影響が無い」と判断した根拠(本番と DB が分離されている・実ユーザーがいない・外部通知や課金が無効 等)とともに許可リストへ書く.書かれていない環境に Claude は接続しない.**本番はどんな場合も対象外** + - 技術スタックと開発コマンドが固まってから作る.立ち上げ時に作らず,必要になった時点で用意してもよい + - プロジェクト所有のファイルであり `/sync-template` で上書き・削除されない.検証結果の出力先は `.gitignore` に追加する ## 仕様設計 (Specification) diff --git "a/docs/01_GUIDE/GUIDE_02_\343\202\250\343\203\274\343\202\270\343\202\247\343\203\263\343\203\210\351\201\213\347\224\250\343\203\253\343\203\274\343\203\253.md" "b/docs/01_GUIDE/GUIDE_02_\343\202\250\343\203\274\343\202\270\343\202\247\343\203\263\343\203\210\351\201\213\347\224\250\343\203\253\343\203\274\343\203\253.md" index 429b73d..04fef34 100644 --- "a/docs/01_GUIDE/GUIDE_02_\343\202\250\343\203\274\343\202\270\343\202\247\343\203\263\343\203\210\351\201\213\347\224\250\343\203\253\343\203\274\343\203\253.md" +++ "b/docs/01_GUIDE/GUIDE_02_\343\202\250\343\203\274\343\202\270\343\202\247\343\203\263\343\203\210\351\201\213\347\224\250\343\203\253\343\203\274\343\203\253.md" @@ -1,13 +1,14 @@ # エージェント運用ルール (Agent Operation Rules) -実装品質を担保するために,3 つの専門エージェントを用いた開発パイプラインを定義する. +実装品質を担保するために,3 つの専門エージェント(コーディング・テスト・リファクタリング)を用いた開発パイプラインを定義する.加えて,検証プロファイルを用意したプロジェクトでは検証エージェントが任意のフェーズとして加わる. 本ガイドは運用の**原則**を定める.各フェーズの具体的な実行手順(委譲プロンプト・分岐・確認メッセージ)は `/implement`(`.claude/skills/implement/SKILL.md`)が正本である. ## 基本方針 (Basic Policy) - 「実装完了」とは,コーディング・テスト・リファクタリングの全フェーズが完了した状態を指す. - 各フェーズは専門エージェントが担当し,フレッシュなコンテキストでレビューする.各エージェントは前フェーズのサマリーを入力として受け取り,加えて `git diff` で実際の変更差分を自ら確認する. -- フェーズのスキップは原則禁止する.リファクタリング不要の判断は Refactorer エージェント自身が行う. +- フェーズのスキップは原則禁止する.リファクタリング不要の判断は Refactorer エージェント自身が行う.※ 例外は任意フェーズの Phase 1b(検証)で,前提が揃わない場合は理由を添えてスキップする. +- 検証エージェント(Phase 1b)は人間の動作確認を**置き換えない**.機械的に確かめられる範囲を先に潰し,人間が見る範囲を狭めるための前段であり,verifier の OK を人間の OK の代わりにしてはならない. - テストは実装コードではなく**仕様**(`docs/04_SPEC/`)を基準に書く.実装が仕様と異なればテストが失敗し,バグを検出する. - 人間の本質的な役割は「AI にできない判断と確認」であり,各フェーズの品質のゲートキーパーとなる. @@ -16,9 +17,13 @@ | エージェント | 役割 | フェーズ | | --- | --- | --- | | `coder` | 機能実装・バグ修正 | Phase 1 | +| `verifier` | 動作・エッジケースの確認(任意.対象はローカル環境と,プロファイルが許可した検証用環境) | Phase 1b | | `tester` | 仕様ベースのテスト作成・実行 | Phase 2 | | `refactorer` | コード品質の改善 | Phase 3 | +※ `verifier` は**検証プロファイル(`.claude/verify-profile.md`)を用意したプロジェクトでのみ動く任意のエージェント**.プロファイルが無ければ Phase 1b は理由を 1 行添えてスキップされ,動作確認は従来どおり人間が行う.単独では `/verify` で起動する.検証手段・検証対象の環境・実行コマンド・前提条件といったプロジェクト固有の情報はすべてプロファイルに書き,エージェント側は持たない(雛形: `.claude/skills/verify/profile-template.md`). +※ **検証対象の環境は既定でローカルのみ**.staging 等の検証用環境は,人間がプロファイルの「検証対象の環境」に「いじっても影響が無い」と判断した根拠つきで書いた接続先に限り許可される(書かれていない環境を verifier が自分の判断で使うことはない).**本番はどんな場合も対象外**. + ### 補助エージェント (Support Agents) 実装パイプライン外でも,「思考(判断・文章品質)はメインループ,機械的な実行は軽量エージェント」の分担で使用量を節約する.サブエージェントは新規の小さいコンテキストで起動されるため,メインループのプロンプトキャッシュを壊さずに,安価なモデルにも上位モデルにも切り替えられる(メインのモデル切替はキャッシュ全損になるため行わない).以下は各 skill から起動される補助エージェント. @@ -35,7 +40,8 @@ | フェーズ | 担当 | やること | 人間の関与 | | --- | --- | --- | --- | -| Phase 1: コーディング | `coder` | 実装に集中する(テスト・リファクタ・コミットはしない) | 完了後にブラウザ・実機で動作確認(AI にできない確認).NG ならフィードバックして修正させる | +| Phase 1: コーディング | `coder` | 実装に集中する(テスト・リファクタ・コミットはしない) | — | +| Phase 1b: 検証(任意) | `verifier` | 検証プロファイルに従い,ローカル環境(およびプロファイルが許可した検証用環境)で動作とエッジケースを確認する(コードは直さない).プロファイルが無ければスキップ | 完了後にブラウザ・実機で動作確認(AI にできない確認).verifier が確認済みの項目は除き,残りを確認する.NG ならフィードバックして修正させる | | Phase 2: テスト | `tester` | 仕様(`docs/04_SPEC/`)を基準にテストを作成・実行する | テスト失敗時のみ方針判断(仕様・実装・テストのどれの問題か).全成功なら自動で Phase 3 へ | | Phase 3: リファクタリング | `refactorer` | テストを安全網に,挙動を変えずに品質を改善する | なし(不要判断は Refactorer 自身が行う) | | Phase 4: ドキュメント更新 | メイン | `docs/`・進捗記録を必要に応じて更新し,結果を `.claude/commit-context.md` に書き出す | 更新がある場合のみ確認 | @@ -49,6 +55,8 @@ ※ リファクタリングとテストは実装と一体の成果物として,まとめてコミットする.必要に応じて分割コミットも可. +※ **例外(プロジェクト立ち上げ)**: `/setup` は立ち上げ中,各フェーズの成果物が確定した時点で `chore/project-setup` ブランチに自律コミットする(GUIDE_01「立ち上げ中の Git 運用」).push・PR・マージは行わず,`main` への取り込みは完了後に人間が `/commit push` 等で行う. + ※ **例外(無人運転)**: 以下のループは CLAUDE.md の「`/commit` 自発実行禁止」ルールに対するユーザー承認済みの例外として,専用ブランチに自律コミットする.いずれも push・PR・マージ・`main` への操作は行わず,取り込み可否は人間が判断する. - `/auto-refactor`(リファクタ/ドキュメント整理ループ)→ `refactor/` 専用ブランチ diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index 1d7af4a..baedb6c 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -22,11 +22,11 @@ **.NET 補完ライブラリ** - `System.Buffers` (4.6.1) -- `System.Drawing.Common` (8.0.0) +- `System.Drawing.Common` (8.0.31) - `System.Memory` (4.6.3) - `System.Numerics.Vectors` (4.6.1) - `System.Runtime.CompilerServices.Unsafe` (6.1.2) -- `System.ValueTuple` (4.5.0) +- `System.ValueTuple` (4.6.2) ※ `System.Drawing.Common` と `System.ValueTuple` を除く 4 件は OpenCvSharp 4.13 の推移的依存であり,バージョンは相互に不可分(`System.Memory` 4.6.3 が他 3 件の下限を指定している).個別に上げ下げしないこと. @@ -34,9 +34,10 @@ テストプロジェクトも `packages.config` で管理する(本体と同じレガシー形式・x64). -- `NUnit` (3.14.0): テストフレームワーク -- `NUnit3TestAdapter` (4.5.0): テストランナー連携(Visual Studio / vstest) -- `Microsoft.NET.Test.Sdk` (17.8.0): テスト実行基盤 +- `NUnit` (4.6.1): テストフレームワーク + - net48 向けの依存として `System.Threading.Tasks.Extensions` (4.6.3)・`System.ValueTuple` (4.6.2) も参照する +- `NUnit3TestAdapter` (4.6.0): テストランナー連携(Visual Studio / vstest) +- `Microsoft.NET.Test.Sdk` (17.14.1): テスト実行基盤(依存として `Microsoft.CodeCoverage` 18.10.1 が入る) - ※ `OpenCvSharp4` / `OpenCvSharp4.runtime.win` (4.13.0) も参照する.`Mat` 演算はネイティブ DLL に依存するため,テストプロジェクトも **x64** でビルドする. - ※ あわせて `System.Buffers` / `System.Memory` / `System.Numerics.Vectors` / `System.Runtime.CompilerServices.Unsafe` を本体と同一バージョンで参照する(OpenCvSharp 4.13 の推移的依存.`packages.config` 方式では推移的依存が自動解決されないため明示が要る). diff --git "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index 0976cbc..a31ad7f 100644 --- "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -65,6 +65,13 @@ - Lumenera 製カメラ(Lw110)の使用に必要. - `lumenera.api.dll` はリポジトリ内に同梱されている. +### Python と bash(Claude Code のフック用) + +- `.claude/settings.json` のフック(リポジトリ外アクセス制限・通知)の実行に必要.アプリのビルド・実行には不要なため,Claude Code を使わない場合は省略できる. +- **Python 3.7 以上**: フックは `bash .claude/hooks/run_python.sh` 経由で起動し,`python3` → `python` → `py` の順に実際に起動できるものを使う(いずれか 1 つで起動できればよい). +- **bash**: Git for Windows に付属するものを使う.Visual Studio と併せて `git` を入れていれば追加インストールは不要. +- ※ Python が見つからないとアクセス制限フックが Read・Write・Edit・Glob・Grep・Bash を**すべてブロックする**(通知フックは何もせず素通りする). + ## リポジトリのセットアップ (Repository Setup) 1. リポジトリのクローン diff --git "a/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index cc15aec..6d5aeac 100644 --- "a/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_03_\347\256\241\347\220\206\350\200\205\347\224\250\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -57,4 +57,5 @@ - 現時点で CI(GitHub Actions 等)は構築していない.ビルド・テストはローカルで行う([ENV_04_開発コマンド](ENV_04_開発コマンド.md)). - 将来 CI を導入する場合は,x64・packages.config・ネイティブ DLL(OpenCvSharp)依存を満たすランナー構成が必要になる点に注意する. - `.github/dependabot.yml` で依存パッケージの定期更新 PR を設定している(NuGet: `TIASshot`/`TIASshot.Tests` の各 packages.config,週 1 回,マイナー/パッチは 1 PR にまとめる.メジャー更新も個別 PR で作成する).GitHub Actions の更新も対象に含めてあるため,CI を追加した時点で自動的に追従される. +- **OpenCvSharp4 系(5 パッケージ)は `opencvsharp` 専用グループに分離してある**.OpenCvSharp は OpenCV 本体に追随するバージョニングで semver 契約を持たず,マイナー扱いの bump でも破壊的変更が入るため(4.10→4.13 で ArUco API が変更され,マイナー扱いのグループ PR がビルド不能になった実績がある).マネージドとネイティブ(`runtime.win`)を常に同一バージョンで揃える必要があるので,規模を問わず 1 PR に束ねる.security update 経路にも同じ束ね方を効かせるため `applies-to: "security-updates"` のミラーグループを併置している.**このグループは自動マージ禁止**で,fixture 回帰または実機確認を経て人間がマージする(`/deps-update` 側のガードは `.claude/deps-update-skip.md`). - Dependabot の PR は `/deps-update` で処理する(ゲートを満たすものだけマージし,メジャー更新・CI 赤は分析付きで報告される).**本プロジェクトは CI が無いためゲートはローカル検証(x64 ビルド+テスト)で判定される**点に注意する. 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 97c72ad..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" @@ -6,16 +6,18 @@ - ビルドは **x64 のみ**(`TIASshot.sln` は `Debug|x64` / `Release|x64` のみ定義).OpenCvSharp 等がネイティブ DLL に依存するため `AnyCPU` は使わない. - レガシー形式(packages.config)のため **`dotnet build` / `dotnet test` は使えない**.MSBuild と `vstest.console.exe` を使う. -- コマンドは PowerShell で実行する.VS のエディション差(Community / Professional / Enterprise)を吸収するため,`vswhere` で MSBuild・vstest の場所を解決する. +- コマンドは PowerShell で実行する.VS のエディション差(Community / Professional / Enterprise)と Build Tools のみの環境を吸収するため,`vswhere` で MSBuild・vstest の場所を解決する. ## ツールパスの解決 (Locating Build Tools) ```powershell -$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath +$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -property installationPath $msbuild = Join-Path $vs "MSBuild\Current\Bin\MSBuild.exe" $vstest = Join-Path $vs "Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe" ``` +※ `-products *` は必須.これが無いと `vswhere` は通常の VS 製品しか列挙せず,**VS Build Tools のみ**の環境では何も返さない(`$vs` が空になり `Join-Path` が失敗する). + 以降のコマンドは `$msbuild` / `$vstest` を解決済みとして記載する. ## NuGet 復元 (Restore) @@ -54,6 +56,34 @@ - ネイティブ DLL を x64 でロードするため `/Platform:x64` は必須. - Visual Studio 上ではテストエクスプローラーから実行できる(プラットフォームを x64 にすること). +## クリーン環境での検証 (Clean Verification) + +**ベースライン計測・マージ前検証・「テストが通ることの確認」は,必ず `bin` / `obj` を削除してから行う.** + +`bin` に前回のビルド成果物が残っていると,過去のビルドでコピーされた DLL が残存してアセンブリ解決が偶然成立し,**依存の記述漏れを隠してしまう**(Issue #39: テストプロジェクトに OpenCvSharp の推移的依存が無いにもかかわらず,汚れた `bin` では全緑に見えていた).同じマシンで作業し続ける限り気づけないため,計測・検証の前に毎回消す. + +```powershell +# bin / obj を削除してからビルド・テストする +Get-ChildItem -Path TIASshot, TIASshot.Tests -Include bin, obj -Directory -Recurse | Remove-Item -Recurse -Force +& $msbuild TIASshot.sln /t:Build /p:Configuration=Debug /p:Platform=x64 +& $vstest "TIASshot.Tests\bin\x64\Debug\TIASshot.Tests.dll" /Platform:x64 +``` + +依存パッケージを追加・変更した PR の検証では,これに加えて `packages` も退避して `nuget restore` からやり直し,**新しいマシンでの `git clone` 直後と同じ状態**を再現する. + +```powershell +# clone 相当の完全クリーン検証(packages も退避する) +Get-ChildItem -Path TIASshot, TIASshot.Tests -Include bin, obj -Directory -Recurse | Remove-Item -Recurse -Force +Rename-Item packages packages.bak # 復元に失敗したときのために削除せず退避する +nuget restore TIASshot.sln +& $msbuild TIASshot.sln /t:Build /p:Configuration=Debug /p:Platform=x64 +& $vstest "TIASshot.Tests\bin\x64\Debug\TIASshot.Tests.dll" /Platform:x64 +``` + +- 合格条件: **テストが全件成功し,ビルド警告が増えていないこと**. +- `packages` の復元にはネットワークと時間がかかるため,通常の開発サイクルでは `bin` / `obj` の削除までで運用し,完全クリーン検証は依存変更時とマージ前に行う. +- 検証が終わったら退避した `packages.bak` は削除してよい(`nuget restore` で再取得できるため). + ## 実行 (Run) ビルド後,構成に対応した実行ファイルを起動する.実行時参照ファイル(`config.xml`・カメラ設定 XML・TCC の CSV)が同ディレクトリに配置済みであること([ENV_02_環境構築手順](ENV_02_環境構築手順.md)). @@ -67,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 0af3d99..f43d319 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 e4eaefc..acabf36 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -121,3 +121,44 @@ - マージ必須ファイルは上書きせず差分統合した(`.gitignore` は VS/NuGet 等の固有ルールを保持,`CLAUDE.md` は共通セクションのみ更新,`settings.json` は既存の PreToolUse フック配線を保持して `permissions.allow` を追加).テンプレート由来ファイルのローカル改変は 0 件だったため台帳は空で開始. - 続けて,新しい GUIDE_01 が前提にする Dependabot をセットアップした.リポジトリ設定(alerts / security updates)を `gh api` で有効化し,`.github/dependabot.yml`(NuGet: `TIASshot`/`TIASshot.Tests` の packages.config,週 1 回,マイナー/パッチをまとめる.メジャー更新も個別 PR で作らせる.GitHub Actions も対象)を追加した.手順は ENV_03 に転記. - 後段: CI(GitHub Actions)が無いため,`/deps-update` のマージゲートはローカル検証(x64 ビルド+テスト)で判定することになる.OpenCvSharp や実機カメラ SDK に関わる更新は実機確認が要る点は変わらない. + +- [x] **初回の `/deps-update` 実行:Dependabot PR 5 件が全てビルド不能と判明し,追従を Issue 化** (2026-08-28): 初回の依存更新巡回で open な Dependabot PR 5 件(メジャー 4・マイナーグループ 1)を検証した結果,**マージ可能なものは 1 件も無かった**.前エントリの後段で予告したとおりローカル検証(x64 ビルド+テスト)がゲートとして機能し,GitHub 上は全件 `MERGEABLE / CLEAN` だった破壊的変更を水際で止めた.追従作業は #29〜#32 として起票し,本コミットはうち #32 を実施. + - 原因①(グループ PR #24): `minor-and-patch` グループに入っていた OpenCvSharp4 の bump が,semver 上は minor ながら ArUco API を破壊していた(`PredefinedDictionaryName` の廃止・改名,`CvAruco.DetectMarkers` の削除).OpenCvSharp のバージョンは OpenCV 本体に追随しており **semver 契約を持たない**ため,第 2 セグメントの bump は常にメジャー相当として扱う必要がある.グループ PR は部分マージできないので,専用グループに分離しない限り残り 12 パッケージも道連れで止まる(#29). + - 原因②(メジャー 4 件): Dependabot の packages.config アップデータが,同時 open の他 PR の解決結果を csproj に混入させ,どの PR も「csproj が自分の packages.config に無いパッケージのパスを参照する」状態になっていた.csproj はパスにバージョンがハードコードされる形式のため,`EnsureNuGetPackageBuildImports` の `` が発火して確実にビルドが落ちる.根本原因は packages.config 形式そのもので,テストプロジェクトの PackageReference 移行が構造的対策になる(未着手). + - 設計判断(NUnit 4 追従の順序): `ClassicAssert` への置換は NUnit 3.14 でコンパイルできず「上げてから直す」と main が壊れる期間ができる.一方,制約モデル(`Assert.That` + `Is.*` / `Does.*`)は両バージョンで動くため,先に移行しておけば破壊期間ゼロで追従できる(#30).ArUco は逆に新旧両対応の中間形が作れず,パッケージ更新とコード移行を同一 PR で原子的に入れるしかない(#31). + - 判断待ちとして残したもの: #25・#26・#28 は net48 では更新の利得が無い(それぞれ型フォワード facade で中身同一/死んだ参照/依存 0 件→約 50 パッケージの死荷重)ため見送りが妥当だが,`@dependabot ignore` の投稿は人間の決定なので保留.分析結果は各 PR のコメントと `.claude/deps-update-report.md` に残した. + - 付随して判明: ENV_04 の `vswhere` は `-products *` が無く,VS Build Tools のみの環境(IDE 未導入)では何も返さないため `$msbuild` / `$vstest` が解決できなかった.検証を委譲したエージェントが実際にこれで検証不能に陥ったので修正した(#32). + +- [x] **Dependabot 追従作業の実施と,クリーンビルドでのみ顕在化する依存欠落の発見** (2026-08-29): 前エントリで起票した #29・#30・#32 を実装してマージし,#31(ArUco 移行)は実機確認待ちの PR として残した.その検証過程で,**`main` に元から存在した「クリーンビルドするとテスト 41 件が落ちる」不具合**を発見して修正した(#39). + - 失敗パターン(最重要): `bin` に前回のビルド成果物が残っていると,過去にコピーされた DLL でアセンブリ解決が偶然成立し,**依存の記述漏れを隠してしまう**.テストプロジェクトは OpenCvSharp が要求する `System.Memory` / `System.Runtime.CompilerServices.Unsafe` を `packages.config` にも csproj にも持っていなかったが,同じマシンで作業し続ける限り誰も気づけなかった.この作業中に「`main` のベースラインは全緑」と**誤った計測結果を一度報告している**.対策として ENV_04 に「計測・マージ前検証は `bin`/`obj` を消してから,依存を変えた PR では `packages` も退避して `nuget restore` からやり直す」手順を明記した.ただし運用頼みの弱い防御なので,構造的な解は CI(#40 で起票). + - 設計判断(推移的依存をどのバージョンで足すか): 同じ「不足している推移的依存を追加する」対処でも,**要求元のバージョンによって正解が変わる**.#39(OpenCvSharp 4.10 のまま)は本体と同一の現行版を足すのが正しい.4.10 が要求するアセンブリバージョンと NuGet パッケージが供給する版が厳密一致し,バインディングリダイレクトが不要になるため.一方 #31(4.13 へ更新)は依存を 4.6.x 系に上げる必要がある.4.13 の要求版はどの lib にも存在せずリダイレクトを自動生成できないので,手書きのダウングレードリダイレクトを保守し続けることになるため.「競合を避けたいから新版に先合わせする」は後者の罠を前者に持ち込むので採らない. + - 原因(#31 側): OpenCvSharp は 4.13 で `lib/net48` を廃止し `lib/netstandard2.0` のみになった.net48 プロジェクトが netstandard2.0 アセンブリを消費する形になり,`System.Memory` 等への推移的依存が新たに発生する.`packages.config` は推移的依存を自動解決しないため,本体・テストの両方に明示的な参照追加が要る. + - 後段: #31 は**実機・実チャートでの確認待ち**(PR #36).OpenCvSharp のネイティブ側が変わるため,合成 fixture の自動テストが緑でも検出性能・色変換の数値差は実機でしか担保できない.CI 導入(#40)が入れば `/deps-update` のマージゲートがローカル検証から CI 緑に変わり,判断待ちで残っている #25・#26・#28 の処理も進めやすくなる.テストプロジェクトの PackageReference 移行(推移的依存が自動解決される根本対策)は #31 マージ後・CI 導入後に検討する. + +- [x] **テンプレートを最新版に同期(検証エージェント・フック起動ラッパー・通知フックの導入)** (2026-09-18): `/sync-template` で programming-template を `0ac68c7` → `60ab68c` に追従.検証エージェント `verifier`(`/verify`・`/implement` Phase 1b),Python フックの起動ラッパー `run_python.sh`,ntfy 通知フック `notify.py` とそのテストが入った.テンプレート由来ファイルのローカル改変は今回も 0 件で,台帳は空のまま. + - 設計判断(検証プロファイルを作らない): Phase 1b は `.claude/verify-profile.md` がある時だけ動く任意フェーズだが,本プロジェクトでは作らない.verifier が操作するのは WinForms UI と実機カメラであり,これは `.claude/rules/testing.md` が「自動テスト対象外=ハードウェア結合層」と定義した領域そのもので,プロファイルを書いても担保できる範囲が増えないため.動作確認は従来どおり実機で人間が行う(Phase 1b は理由付きでスキップされる). + - 失敗パターン: `/sync-template` は**自分自身の定義も同期対象**のため,旧ルールで走らせた処理を新ルールで見直す必要がある.今回は同期後の skill で `.claude/tests/`(テンプレート自身のメタテスト.派生プロジェクトでは必ず失敗する)が同期対象外に追加されており,旧ルールで一度コピーしてから取り消した.skill 自体に差分がある回は,コピー後に新しい除外規則を読み直すこと. + - 上流に差し戻した不具合: 同期で入った `test_hooks.py` は 52 件中 3 件が必ず落ちる.`TestCheckSync` が team 層の `check_sync.sh` の存在をガードしておらず,solo プロジェクト全てで失敗する(programming-template#43 として起票).プロジェクト固有の都合ではなくテンプレート側の欠陥なので,ローカル改変(台帳登録)ではなく上流修正に倒した. + - フックの前提ツール(Python 3.7 以上と bash)を ENV_02 に追記した.Python が見つからないとアクセス制限フックが Read・Write・Edit・Glob・Grep・Bash を**全てブロックする**ため,環境再構築時に踏むと原因が分かりにくい. + - 後段: #31(ArUco API 移行.PR #36)の実機確認待ちは変わらず継続. + +- [x] **上流の修正を取り込み,共通層テストのチーム層依存を解消** (2026-09-18): 前エントリで programming-template#43 として差し戻した不具合が上流で修正されたため,テンプレートを `60ab68c` → `3db9db5` に同期した.`TestCheckSync` に存在ガードが入り,solo でも失敗せず skip される(52 件中 3 件 skip で全緑). + - 上流は個別のガード追加に留めず,「共通層のファイルはチーム層ファイルの存在を前提にしてはならない」を `/sync-template` の不変条件として明文化し,AST で共通層テストを検査するメタテストを追加した(メタテストは `.claude/tests/` 配下のため本プロジェクトには配布されない).同種の退行は今後テンプレート側で止まる. + +- [x] **テンプレートを最新版に同期(`/setup` のフェーズごとコミットと環境構築の実行)** (2026-09-29): `/sync-template` で programming-template を `3db9db5` → `00dd94e` に追従.変更は `/setup` 系(skill・GUIDE_01/02)と CLAUDE.md「Git 運用」の自律コミット例外への `/setup` 追加のみで,立ち上げ済みの本プロジェクトのコードへの影響は無い.台帳登録・未登録改変ともに 0 件. + +- [x] **2 回目の `/deps-update` と NUnit 4.6.1 への移行(#27 の追従)** (2026-09-29): 依存更新の巡回で,マイナー/パッチのグループ PR #44(Test.Sdk 17.14.1・NUnit3TestAdapter 4.6.0・System.* 4.6 系・System.Drawing.Common 8.0.31)をローカル検証(x64 クリーンビルド+テスト全緑)でマージした.メジャーの #45(System.Drawing.Common 10)・#46(Test.Sdk 18)は前回と同じく,他 PR の解決結果が csproj に混入してビルド不能だったため見送り(#46 は ignore を投稿,#45 は OpenCvSharp 4.13 が 10 系を要求するため #36 の後に一括追従).続けて #27 を `/implement` で追従し,テストプロジェクトを NUnit 4.6.1 に上げた. + - 前回(2026-08-28)の判断どおり,制約モデルへの先行移行が効いてクラシック Assert の書き換えは 0 件で済んだ. + - 失敗パターン: 代わりに 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` 等が新しいコントロールを参照するようになった場合はヘッドレス用コンストラクタの追従が要る. + +- [x] **OpenCvSharp 4.13 の ArUco API 移行を main に取り込み(#31・PR #36)** (2026-09-29): 実機確認待ちで保留していた PR #36 に main(NUnit 4.6.1 移行・セルフテスト導入など 20 コミット)を取り込んで競合を解消し,実機確認を経てマージした.競合した依存設定は main 側を正とし,OpenCvSharp の 4.13 化(`lib/netstandard2.0` 参照・runtime の `.targets` 取り込み・`OpenCvSharp` のバインディングリダイレクト)だけを当て直した. + - 失敗パターン: PR の保留中に main へ入ったセルフテスト(`SelfTestChart`・`SelfTestRunner`・`SelfTestChartTests`)が旧 API(`PredefinedDictionaryName`・`CvAruco.DetectMarkers`)で書かれており,競合解消だけではビルドが壊れる状態だった.4.10 と 4.13 の両方で動く書き方が無い API 移行を長く保留すると,その間に増えた旧 API の利用箇所も移行対象になる.取り込み時は `grep` で旧 API の残存を確認すること. + - 後段: System.Drawing.Common 10 系への追従(#45)が可能になった. 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()