diff --git a/CLAUDE.md b/CLAUDE.md index 2597f45..e9eb182 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_02 ステップ 7(GUI: 結果画面・結合)完了.ステップ 2〜7 の実装が完了し,残りはユーザー作業(ステップ 5 の旧 exe 採取=TEST_01,ステップ 6〜7 の実機確認)とステップ 8(配布,任意) +最新: PLAN_02 ステップ 5(回帰テスト)完了・**M1 達成**(旧 exe 実測 12 ケース全一致).全ステップ完了,残りはステップ 8(配布,任意)のみ ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git "a/docs/06_TEST/TEST_01_\345\233\236\345\270\260\343\203\206\343\202\271\343\203\210\346\216\241\345\217\226\346\211\213\351\240\206.md" "b/docs/06_TEST/TEST_01_\345\233\236\345\270\260\343\203\206\343\202\271\343\203\210\346\216\241\345\217\226\346\211\213\351\240\206.md" index f3881ed..1ce797e 100644 --- "a/docs/06_TEST/TEST_01_\345\233\236\345\270\260\343\203\206\343\202\271\343\203\210\346\216\241\345\217\226\346\211\213\351\240\206.md" +++ "b/docs/06_TEST/TEST_01_\345\233\236\345\270\260\343\203\206\343\202\271\343\203\210\346\216\241\345\217\226\346\211\213\351\240\206.md" @@ -8,6 +8,20 @@ - 診断ロジックの仕様: [SPEC_02_診断ロジック仕様.md](../04_SPEC/SPEC_02_診断ロジック仕様.md) - 旧アプリの操作: [SPEC_01_画面仕様.md](../04_SPEC/SPEC_01_画面仕様.md) +## 自動採取(推奨)(Automated Capture) + +本手順の操作は `scripts/auto_capture.py` で**全自動化済み**(ステップ 5 で使用し,12 ケースの採取に成功). +旧 exe の起動 → 変換行列・画像の読込 → クリック → 診断 → 8 値の読取 → CSV 追記を 1 枚ごとに繰り返す. + +```bash +uv run python scripts/auto_capture.py --images-dir <単色PNGフォルダ> --matrix <変換行列.csv> --out <出力CSV> --verbose +``` + +- 実行中は PC を操作しないこと(ウィンドウメッセージ直送のためフォーカスには依存しないが安全のため) +- まず `--single <ファイル名>` で 1 枚試すことを推奨 +- 補助: `scripts/read_legacy_values.py` は起動中の旧 exe から 8 値を読み取るだけの単体ツール(旧実装のバグで紫スコアが画面表示から読めないため,WM_GETTEXT で取得する) +- 以下の手動手順は,スクリプトが環境差で動かない場合のフォールバックとして残す + ## 目的 (Purpose) 新実装(`src/tiasjudge/core/`)が旧実装と**同じ診断結果**を返すことを証明する(PLAN_01 成功基準・M1). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a73fa16..ad8533d 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -59,3 +59,8 @@ - [x] **結果画面の表示専用化(操作をメイン画面へ移動)+英語説明文の行単位サイズ**: ユーザー要望による変更(実機確認済み) - 結果画面から言語切替・閉じるボタンを削除し表示専用に(患者側モニタに操作 UI を出さない).メイン画面に「結果画面の操作」グループを新設(言語切替は常時有効・閉じるは結果画面が開いている時のみ有効.言語状態はメイン画面が保持し次に開く画面へ継承).プレビューは `--english` オプションで英語確認 - 英語の説明文サイズは行単位の固定値(Red / Dark red 36 px・Pale red 30 px・他 40 px).一律縮小 → 行単位と試行し,「スコア付き強調行でも縮小なしで収まる実測最大値」を行ごとに採用(強調時にサイズが変わるチカつきを排除).Red は当初 40 px 指定だったが実測で収まらず 36 px に変更(ユーザー承認済み) +- [x] **PLAN_02 ステップ 5 完了(回帰テスト)— M1 達成**: 旧 exe の実測 12 ケースで RGB→Lab(±0.51)・5 色スコア(完全一致)・失敗判定(完全一致)の**全 25 テストが一致**.診断ロジックの旧実装同一性が証明された + - 採取は全自動化(`scripts/auto_capture.py`): 旧 exe を 1 枚ごとに起動し,Win32 メッセージ(WM_SETTEXT / クリック / WM_GETTEXT)で操作・読取.手動採取は不要になった + - 経緯: ① 旧実装のバグで紫スコアが画面から読めない(IDC_PURPLE のフォント指定ミスで 150px の巨大文字)→ WM_GETTEXT で吸い出す `scripts/read_legacy_values.py` を作成 ② それを発展させ全自動化 ③ 初回失敗の原因は「結果画面(#32770)をエラーダイアログと誤検出」というスクリプト側のバグで,旧 exe への WM_SETTEXT 入力は最初から機能していた + - failed 判定はスコア組(42/0/5/11/3)から導出(旧 ResultDlg の表示条件そのもの).この変換行列では純色だけでなく一部の舌色(115,62,68 / 170,92,112 / 196,74,74)も失敗パターンになることが判明(学習データ域外) + - 残: `lab_to_scores.csv`(高精度照合用の任意フィクスチャ)は未採取のまま skip(Debug ビルドが必要.必要になったら TEST_01 末尾の手順で) diff --git a/scripts/auto_capture.py b/scripts/auto_capture.py new file mode 100644 index 0000000..b76f628 --- /dev/null +++ b/scripts/auto_capture.py @@ -0,0 +1,739 @@ +"""旧 exe を自動操作して回帰テストの採取表を作る(TEST_01 の全自動化). + +単色 PNG を 1 枚ずつ旧 exe に読み込ませて «舌色診断» を実行し,表示された +L\\*a\\*b\\* と 5 色スコアを `WM_GETTEXT` で読み取って CSV に追記する. +人手の操作は不要(画像の枚数だけ exe の起動と終了を繰り返す). + +依存は Python 標準の `ctypes` / `subprocess` のみ.**Windows 専用**. +値の読み取りとパースは `read_legacy_values.py` を再利用する. + +## 使い方 + +```bash +uv run python scripts/auto_capture.py \\ + --images-dir <単色 PNG のフォルダ> \\ + --matrix <変換行列 .csv のパス> \\ + --out out/capture_sheet.csv +``` + +まず 1 枚だけで動作を確かめる(推奨): + +```bash +uv run python scripts/auto_capture.py \\ + --images-dir <フォルダ> --matrix <パス> --out out/capture_sheet.csv \\ + --single rgb_156_102_098.png +``` + +画像ファイル名は `rgb_RRR_GGG_BBB.png`(`scripts/generate_regression_images.py` の出力) +とし,そこから入力 RGB を読み取る.出力はヘッダ付きの CSV へ**追記**する. + +## 動作の流れ(1 枚あたり) + +1. 旧 exe を起動する(画像ごとに起動・終了する.理由は下の «設計判断») +2. 変換行列と画像のパスを,参照用エディットコントロールへ直接設定する +3. 画像上を 4 点クリックする(ダイアログへ `WM_LBUTTONDOWN` / `WM_LBUTTONUP` を送る) +4. «舌色診断» ボタンを押し,`L*` の表示が変わるまで待つ +5. 8 値を読み取り,CSV に 1 行追記する +6. 結果画面と本体を閉じる + +## 設計判断 + +- **画像ごとに exe を起動・終了する**: «診断続行» の確認ダイアログを自動化せずに済み, + クリック数などの内部状態が毎回まっさらになる.1 枚が失敗しても次に影響しない. +- **ファイルダイアログを開かず,エディットコントロールへ直接パスを書き込む**: + 旧実装はパスの読み込みを `EN_CHANGE`(`OnEnChangeMfceditbrowseTransmat` / + `OnEnChangeMfceditbrowseFilename2`)で行っており,`WM_SETTEXT` でも同じ経路が走る. + 参照ボタンは `CMFCEditBrowseCtrl` がエディット内部に描くもので**独立した HWND を持たない** + ため,ボタンを押す方式は座標依存で壊れやすい.うまくいかない環境向けに + `--use-file-dialog` で «参照ボタンの位置をクリックして共通ダイアログを操作する» + 方式も選べるようにしてある. +- **クリックはダイアログ本体へ送る**: 旧実装の `OnLButtonDown` はダイアログの + メッセージマップにあり,座標は**ダイアログのクライアント座標**として扱われる + (`tonguediagnosisMFC2Dlg.cpp:313`).画像コントロールへ送っても届かない. +- **結果画面をエラーダイアログと区別する**: 結果画面(`CResultDlg`)も MFC のダイアログで + クラス名は `#32770` と,メッセージボックスと同じになる.診断が成功すると必ず開くため, + クラス名だけで «エラーが出た» と判定すると必ず失敗する.タイトルに «結果» を含むものは + 結果画面として除外する. +- **診断失敗は読み取った値から判定する**: 旧実装が «Diagnosis failed» を出す条件は + スコア組が (紫 42, 暗赤 0, 紅 5, 淡紅 11, 淡白 3) と一致することなので + (`ResultDlg.cpp` の判定・`core.svm.LEGACY_FAILURE_PERCENTAGES` と同じ), + 結果画面を読む必要が無い. + +うまくいかないときは `--verbose` で各段階の途中経過(設定したパス・クリック数・ +エラーダイアログの文言)を表示できる. + +## 注意 + +- 実行中は旧 exe のウィンドウが繰り返し開閉する.操作中の PC で流さないこと. +- メッセージの送信先は**起動した exe のプロセス ID を持つウィンドウに限定**しており, + 他のアプリのウィンドウには一切送らない.手動で起動した旧 exe が別に開いていても + 影響を受けない(終了させるのも自分が起動したプロセスだけ). +""" + +from __future__ import annotations + +import argparse +import csv +import ctypes +import re +import subprocess +import sys +import time +from ctypes import wintypes +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +# 同じ `scripts/` にあるモジュール(スクリプト実行時は自動で import パスに入る) +import read_legacy_values as legacy + +# --- 旧 exe のコントロール ID(`org/src/tonguediagnosisMFC2/resource.h`)--- + +IDC_PICT = 1000 +IDC_MFCEDITBROWSE_TRANSMAT = 1007 +IDC_BUTTON_CALC = 1008 +IDC_OKETSU = 1009 # L* の表示ラベル +IDC_MFCEDITBROWSE_FILENAME2 = 1017 +IDC_POINT_CLICKED_IDS: tuple[int, ...] = (1029, 1031, 1032, 1033) + +#: 旧 exe の既定の場所(リポジトリ相対.`--exe` で上書きできる) +DEFAULT_EXE_RELATIVE = Path("org/src/x64/Release/tonguediagnosisMFC2.exe") +#: 旧 exe の作業フォルダ(`data.csv` と `img/` を相対パスで開くため) +DEFAULT_WORK_DIR_RELATIVE = Path("org/src/tonguediagnosisMFC2") + +#: 旧 exe のメインウィンドウのタイトルに含まれる文字列 +TITLE_KEYWORD = "TIDA" +#: 結果画面(`CResultDlg`)のタイトルに含まれる文字列. +#: 結果画面も MFC のダイアログなのでクラス名はメッセージボックスと同じ `#32770` になる. +#: 診断が成功すると必ず開くので,エラーダイアログと取り違えないよう除外する +RESULT_TITLE_KEYWORD = "結果" + +#: 採取表(`capture_sheet.csv`)の列 +CSV_FIELDS: tuple[str, ...] = ( + "matrix", + "r", + "g", + "b", + "l_star", + "a_star", + "b_star", + "purple", + "dark_red", + "crimson", + "pink", + "pale", + "failed", + "note", +) + +#: 旧実装が «Diagnosis failed» を表示するスコア組(`core.svm.LEGACY_FAILURE_PERCENTAGES`). +#: 学習データ域外の入力に対する SVM の既定応答であり,この組と完全一致したときだけ失敗とする +LEGACY_FAILURE_SCORES: dict[str, int] = { + "purple": 42, + "dark_red": 0, + "crimson": 5, + "pink": 11, + "pale": 3, +} + +#: 画像ファイル名から入力 RGB を読む(`generate_regression_images.py` の命名) +IMAGE_NAME_PATTERN = re.compile(r"^rgb_(\d{1,3})_(\d{1,3})_(\d{1,3})\.png$", re.IGNORECASE) + +#: クリックする位置(ダイアログのクライアント座標). +#: 旧実装は `point * 2` を画像座標として使い,`x > cols/2` / `y > rows/2` を捨てるため, +#: 1024x1280 の画像では x <= 512・y <= 640 が有効範囲.ROI は ±25 px なので端は避ける +CLICK_POINTS: tuple[tuple[int, int], ...] = ((200, 250), (300, 250), (200, 350), (300, 350)) + +# --- タイムアウト(秒)--- + +WINDOW_TIMEOUT = 20.0 +DIALOG_TIMEOUT = 10.0 +DIAGNOSE_TIMEOUT = 60.0 +SHUTDOWN_TIMEOUT = 10.0 +#: ポーリングの間隔 +POLL_INTERVAL = 0.2 +#: 操作の合間に置く待ち(メッセージ処理が追いつくように) +SETTLE_DELAY = 0.4 + +# --- Win32 API --- + +WM_CLOSE = 0x0010 +WM_SETTEXT = 0x000C +WM_COMMAND = 0x0111 +#: エディットコントロールが «内容が変わった» ことを親へ知らせる通知コード +EN_CHANGE = 0x0300 +WM_LBUTTONDOWN = 0x0201 +WM_LBUTTONUP = 0x0202 +BM_CLICK = 0x00F5 +MK_LBUTTON = 0x0001 +SMTO_ABORTIFHUNG = 0x0002 +SEND_MESSAGE_TIMEOUT_MS = 5000 + +#: 共通ファイルダイアログ(およびメッセージボックス)のウィンドウクラス +DIALOG_CLASS = "#32770" +#: 共通ファイルダイアログのファイル名入力欄・「開く」ボタン +FILE_DIALOG_EDIT_ID = 0x047C +IDOK = 1 + +if sys.platform == "win32": + _user32 = ctypes.WinDLL("user32", use_last_error=True) +else: # pragma: no cover - Windows 以外では起動時に弾く + _user32 = None + + +def _configure_api() -> None: + """本モジュールで使う API の引数・戻り値の型を設定する.""" + legacy.configure_api() + _user32.GetDlgItem.argtypes = [wintypes.HWND, ctypes.c_int] + _user32.GetDlgItem.restype = wintypes.HWND + _user32.PostMessageW.argtypes = [ + wintypes.HWND, + wintypes.UINT, + wintypes.WPARAM, + wintypes.LPARAM, + ] + _user32.PostMessageW.restype = wintypes.BOOL + _user32.SendMessageTimeoutW.argtypes = [ + wintypes.HWND, + wintypes.UINT, + wintypes.WPARAM, + wintypes.LPARAM, + wintypes.UINT, + wintypes.UINT, + ctypes.POINTER(ctypes.c_size_t), + ] + _user32.SendMessageTimeoutW.restype = wintypes.LPARAM + _user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] + _user32.GetWindowThreadProcessId.restype = wintypes.DWORD + _user32.IsWindowVisible.argtypes = [wintypes.HWND] + _user32.IsWindowVisible.restype = wintypes.BOOL + _user32.IsWindow.argtypes = [wintypes.HWND] + _user32.IsWindow.restype = wintypes.BOOL + _user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] + _user32.GetClassNameW.restype = ctypes.c_int + _user32.GetClientRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)] + _user32.GetClientRect.restype = wintypes.BOOL + + +#: `--verbose` が指定されたら途中経過を表示する +_verbose = False + + +def _log(message: str) -> None: + """`--verbose` のときだけ途中経過を表示する.""" + if _verbose: + print(message) + + +class CaptureError(RuntimeError): + """1 枚分の採取に失敗したことを表す(この画像を飛ばして次へ進む).""" + + +@dataclass(frozen=True) +class CaptureTarget: + """採取対象の 1 枚. + + Attributes: + path: 単色 PNG のパス. + rgb: ファイル名から読み取った入力 RGB. + """ + + path: Path + rgb: tuple[int, int, int] + + +# --- Win32 の薄いラッパ --- + + +def _process_id(handle: int) -> int: + """ウィンドウを所有するプロセス ID を返す.""" + pid = wintypes.DWORD(0) + _user32.GetWindowThreadProcessId(handle, ctypes.byref(pid)) + return int(pid.value) + + +def _class_name(handle: int) -> str: + """ウィンドウクラス名を返す.""" + buffer = ctypes.create_unicode_buffer(256) + _user32.GetClassNameW(handle, buffer, 256) + return buffer.value + + +def _send_text(handle: int, text: str) -> bool: + """`WM_SETTEXT` でコントロールに文字列を設定する.""" + buffer = ctypes.create_unicode_buffer(text) + result = ctypes.c_size_t(0) + ok = _user32.SendMessageTimeoutW( + handle, + WM_SETTEXT, + 0, + ctypes.cast(buffer, ctypes.c_void_p).value or 0, + SMTO_ABORTIFHUNG, + SEND_MESSAGE_TIMEOUT_MS, + ctypes.byref(result), + ) + return bool(ok) + + +def _post_click(handle: int, x: int, y: int) -> None: + """クライアント座標 (x, y) への左クリックを送る(押下と解放).""" + position = (y << 16) | (x & 0xFFFF) + _user32.PostMessageW(handle, WM_LBUTTONDOWN, MK_LBUTTON, position) + _user32.PostMessageW(handle, WM_LBUTTONUP, 0, position) + + +def _press_button(handle: int) -> None: + """ボタンを押す(診断のように時間がかかるため送りっぱなしにする).""" + _user32.PostMessageW(handle, BM_CLICK, 0, 0) + + +def _wait_until(predicate, timeout: float, description: str): # noqa: ANN001, ANN202 + """`predicate()` が真の値を返すまで待ち,その値を返す. + + Raises: + CaptureError: `timeout` 秒を過ぎても真にならなかった場合. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = predicate() + if value: + return value + time.sleep(POLL_INTERVAL) + raise CaptureError(f"{description}({timeout:.0f} 秒待っても変化しませんでした)") + + +def _windows_of_process(pid: int) -> list[int]: + """指定プロセスが持つ可視のトップレベルウィンドウを返す.""" + return [ + handle for handle, _title in legacy.list_top_level_windows() if _process_id(handle) == pid + ] + + +def _find_main_window(pid: int) -> int | None: + """旧 exe のメインウィンドウ(タイトルに TIDA を含む)を返す.""" + for handle, title in legacy.list_top_level_windows(): + if _process_id(handle) == pid and TITLE_KEYWORD.casefold() in title.casefold(): + return handle + return None + + +def _popup_text(handle: int) -> str: + """ダイアログ(メッセージボックス)に出ている文言を取り出す. + + どのガードに引っかかったのかを切り分けるために使う. + """ + parts = [legacy.window_text(handle)] + parts.extend( + control.text + for control in legacy.read_controls(handle) + if control.class_name == "Static" and control.text.strip() + ) + return " / ".join(part.replace("\n", " ").strip() for part in parts if part.strip()) + + +def _is_result_window(handle: int) -> bool: + """診断結果画面かどうかを返す(エラーダイアログと区別するため).""" + return RESULT_TITLE_KEYWORD in legacy.window_text(handle) + + +def _find_popup(pid: int, main_window: int) -> int | None: + """旧 exe が出したエラーダイアログ等を返す(無ければ None). + + **結果画面は除外する**.結果画面も `#32770` クラスのダイアログであり, + 診断が成功すると必ず開くため,これをエラー扱いすると必ず失敗してしまう. + """ + for handle in _windows_of_process(pid): + if handle == main_window or _class_name(handle) != DIALOG_CLASS: + continue + if _is_result_window(handle): + continue + return handle + return None + + +def _find_result_window(pid: int, main_window: int) -> int | None: + """診断結果画面を返す(無ければ None).""" + for handle in _windows_of_process(pid): + if handle != main_window and _is_result_window(handle): + return handle + return None + + +def _close_popups(pid: int, main_window: int) -> list[str]: + """旧 exe が出したダイアログ類をすべて閉じ,出ていた文言の一覧を返す. + + エラー用のメッセージボックス(MB_OK)は `WM_CLOSE` で閉じられる. + 出たままだと以降の操作が一切効かなくなるため,各操作の後に呼ぶ. + """ + messages: list[str] = [] + for _ in range(5): + popup = _find_popup(pid, main_window) + if popup is None: + break + messages.append(_popup_text(popup)) + _user32.PostMessageW(popup, WM_CLOSE, 0, 0) + time.sleep(SETTLE_DELAY) + return messages + + +# --- 旧 exe の操作 --- + + +def _launch(exe: Path, work_dir: Path) -> subprocess.Popen[bytes]: + """旧 exe を起動する.""" + return subprocess.Popen([str(exe)], cwd=str(work_dir)) # noqa: S603 - 引数は利用者指定の exe + + +def _shutdown(process: subprocess.Popen[bytes], main_window: int | None) -> None: + """結果画面を閉じてから本体を終了する(応答が無ければ強制終了).""" + pid = process.pid + if main_window is not None: + # 結果画面など,メインウィンドウ以外を先に閉じる + for handle in _windows_of_process(pid): + if handle != main_window: + _user32.PostMessageW(handle, WM_CLOSE, 0, 0) + time.sleep(SETTLE_DELAY) + _user32.PostMessageW(main_window, WM_CLOSE, 0, 0) + try: + process.wait(timeout=SHUTDOWN_TIMEOUT) + except subprocess.TimeoutExpired: + process.terminate() + try: + process.wait(timeout=SHUTDOWN_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + + +def _set_path_via_edit(main_window: int, control_id: int, path: Path, label: str) -> None: + """参照用エディットコントロールにパスを直接書き込む. + + 旧実装はパスの読み込みを `EN_CHANGE` ハンドラで行うが,別プロセスからの + `WM_SETTEXT` では通知が飛ばないことがある.そこでテキストを設定したあと, + エディットコントロールが送るのと同じ `WM_COMMAND`(`EN_CHANGE`)を + 親ダイアログへ明示的に送って,ハンドラを確実に走らせる. + ハンドラは «エディットのテキストを読んで読み込む» だけなので, + 通知が二重になっても結果は変わらない(冪等). + """ + control = _user32.GetDlgItem(main_window, control_id) + if not control: + raise CaptureError(f"{label}の入力欄(ID={control_id})が見つかりません") + if not _send_text(control, str(path)): + raise CaptureError(f"{label}のパスを設定できませんでした: {path}") + _log(f" {label}のテキスト設定後: {legacy.control_text(control)!r}") + + notification = (EN_CHANGE << 16) | (control_id & 0xFFFF) + result = ctypes.c_size_t(0) + _user32.SendMessageTimeoutW( + main_window, + WM_COMMAND, + notification, + control, + SMTO_ABORTIFHUNG, + SEND_MESSAGE_TIMEOUT_MS, + ctypes.byref(result), + ) + time.sleep(SETTLE_DELAY) + + +def _set_path_via_dialog( + main_window: int, control_id: int, path: Path, label: str, pid: int +) -> None: + """参照ボタンを押して共通ファイルダイアログでパスを入力する(代替手段). + + `CMFCEditBrowseCtrl` の参照ボタンは独立した HWND を持たないため, + エディットコントロールの**右端付近**をクリックして開く. + """ + control = _user32.GetDlgItem(main_window, control_id) + if not control: + raise CaptureError(f"{label}の入力欄(ID={control_id})が見つかりません") + + rect = wintypes.RECT() + _user32.GetClientRect(control, ctypes.byref(rect)) + # 右端の内側(参照ボタンが描かれている位置)をクリックする + _post_click(control, max(rect.right - 10, 0), max(rect.bottom // 2, 0)) + + dialog = _wait_until( + lambda: _find_popup(pid, main_window), + DIALOG_TIMEOUT, + f"{label}のファイルダイアログが開きませんでした", + ) + edit = _user32.GetDlgItem(dialog, FILE_DIALOG_EDIT_ID) + if not edit: + raise CaptureError(f"{label}のファイルダイアログに入力欄が見つかりません") + _send_text(edit, str(path)) + time.sleep(SETTLE_DELAY) + + ok_button = _user32.GetDlgItem(dialog, IDOK) + if ok_button: + _press_button(ok_button) + else: + _user32.PostMessageW(dialog, WM_CLOSE, 0, 0) + _wait_until( + lambda: not _user32.IsWindow(dialog), + DIALOG_TIMEOUT, + f"{label}のファイルダイアログが閉じませんでした", + ) + + +def _click_image(main_window: int, pid: int, clicks: int) -> None: + """画像上を指定回数クリックする(ダイアログのクライアント座標へ送る).""" + for index in range(min(clicks, len(CLICK_POINTS))): + x, y = CLICK_POINTS[index] + _post_click(main_window, x, y) + time.sleep(SETTLE_DELAY) + messages = _close_popups(pid, main_window) + if messages: + # 「変換行列を読み込んでください」等のガードに引っかかった状態 + raise CaptureError(f"クリック時にエラーダイアログが出ました: {messages}") + marks = _clicked_marks(main_window) + _log(f" クリック {index + 1} 回目 ({x}, {y}) -> クリック済みマーク {marks} 個") + if marks >= len(IDC_POINT_CLICKED_IDS): + break + + if _clicked_marks(main_window) == 0: + raise CaptureError( + "クリックが 1 点も登録されませんでした" + "(座標が画像の範囲外か,クリックが届いていません)" + ) + + +def _clicked_marks(main_window: int) -> int: + """クリック済みを示すマーク(●)が表示されている数を返す.""" + return sum( + 1 + for mark_id in IDC_POINT_CLICKED_IDS + if _user32.IsWindowVisible(_user32.GetDlgItem(main_window, mark_id)) + ) + + +def _run_diagnosis(main_window: int, pid: int) -> None: + """«舌色診断» を押し,`L*` の表示が変わるまで待つ.""" + l_star = _user32.GetDlgItem(main_window, IDC_OKETSU) + before = legacy.control_text(l_star) if l_star else "" + + button = _user32.GetDlgItem(main_window, IDC_BUTTON_CALC) + if not button: + raise CaptureError("«舌色診断» ボタンが見つかりません") + _press_button(button) + + def diagnosed() -> bool: + if _find_popup(pid, main_window) is not None: + # エラーダイアログが出たら待っても無駄なので抜ける(文言は finally で拾う) + return True + current = legacy.control_text(l_star) + return bool(current.strip()) and current != before + + try: + _wait_until(diagnosed, DIAGNOSE_TIMEOUT, "診断が終わりませんでした") + finally: + messages = _close_popups(pid, main_window) + if messages: + raise CaptureError(f"診断中にエラーダイアログが出ました: {messages}") + _log(f" 診断完了: L* 表示 {before!r} -> {legacy.control_text(l_star)!r}") + + +def capture_one(target: CaptureTarget, options: argparse.Namespace) -> dict[str, object]: + """1 枚分を採取して CSV 1 行分の辞書を返す. + + Raises: + CaptureError: 途中で失敗した場合(呼び出し元はこの画像を飛ばす). + """ + process = _launch(options.exe, options.work_dir) + main_window: int | None = None + try: + main_window = _wait_until( + lambda: _find_main_window(process.pid), + WINDOW_TIMEOUT, + "旧 exe のウィンドウが開きませんでした", + ) + _close_popups(process.pid, main_window) + + set_path = _set_path_via_dialog if options.use_file_dialog else None + for control_id, path, label in ( + (IDC_MFCEDITBROWSE_TRANSMAT, options.matrix, "変換行列"), + (IDC_MFCEDITBROWSE_FILENAME2, target.path, "画像"), + ): + if set_path is None: + _set_path_via_edit(main_window, control_id, path, label) + else: + set_path(main_window, control_id, path, label, process.pid) + messages = _close_popups(process.pid, main_window) + if messages: + raise CaptureError(f"{label}の読み込みでエラーが出ました: {messages}") + _log(f" {label}を設定しました: {path}") + + _click_image(main_window, process.pid, options.clicks) + _run_diagnosis(main_window, process.pid) + + controls = legacy.read_controls(main_window) + result = legacy.parse_values([control.text for control in controls]) + if result.missing: + raise CaptureError(f"値を読み取れませんでした: {', '.join(result.missing)}") + return _build_row(target, result.values, options) + finally: + _shutdown(process, main_window) + + +def _build_row( + target: CaptureTarget, values: dict[str, int], options: argparse.Namespace +) -> dict[str, object]: + """採取表 1 行分の辞書を作る.""" + scores = {key: values[key] for key in LEGACY_FAILURE_SCORES} + failed = "yes" if scores == LEGACY_FAILURE_SCORES else "no" + red, green, blue = target.rgb + row: dict[str, object] = { + "matrix": options.matrix.name, + "r": red, + "g": green, + "b": blue, + "failed": failed, + "note": f"auto_capture {datetime.now().astimezone().isoformat(timespec='seconds')}", + } + row.update({key: values[key] for key in legacy.OUTPUT_FIELDS}) + return row + + +# --- 入出力 --- + + +def collect_targets(images_dir: Path, single: str | None) -> list[CaptureTarget]: + """対象の PNG を集める(ファイル名から RGB を読む).""" + targets: list[CaptureTarget] = [] + paths = sorted(images_dir.glob("*.png")) if single is None else [images_dir / single] + for path in paths: + matched = IMAGE_NAME_PATTERN.match(path.name) + if matched is None: + print(f" スキップ(ファイル名が rgb_RRR_GGG_BBB.png 形式でない): {path.name}") + continue + targets.append(CaptureTarget(path, (int(matched[1]), int(matched[2]), int(matched[3])))) + return targets + + +def append_row(out_path: Path, row: dict[str, object]) -> None: + """CSV に 1 行追記する(ヘッダが無ければ先に書く).""" + out_path.parent.mkdir(parents=True, exist_ok=True) + write_header = not out_path.exists() or out_path.stat().st_size == 0 + with out_path.open("a", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=CSV_FIELDS) + if write_header: + writer.writeheader() + writer.writerow(row) + + +def _build_parser() -> argparse.ArgumentParser: + """コマンドライン引数のパーサを作る.""" + parser = argparse.ArgumentParser( + description="旧 exe を自動操作して回帰テストの採取表を作る.", + ) + parser.add_argument("--images-dir", type=Path, required=True, help="単色 PNG が入ったフォルダ") + parser.add_argument("--matrix", type=Path, required=True, help="変換行列 CSV のパス") + parser.add_argument("--out", type=Path, required=True, help="出力する採取表 CSV のパス") + parser.add_argument("--single", help="この 1 枚だけ採取する(ファイル名.動作確認用)") + parser.add_argument("--exe", type=Path, help="旧 exe のパス(既定はリポジトリ内の Release)") + parser.add_argument( + "--work-dir", + type=Path, + help="旧 exe の作業フォルダ(data.csv と img/ のある場所)", + ) + parser.add_argument( + "--clicks", + type=int, + default=len(CLICK_POINTS), + help=f"画像をクリックする回数(既定 {len(CLICK_POINTS)}.1〜4)", + ) + parser.add_argument( + "--use-file-dialog", + action="store_true", + help="パス入力に共通ファイルダイアログを使う(既定は入力欄へ直接書き込み)", + ) + parser.add_argument( + "--verbose", action="store_true", help="各段階の途中経過を表示する(デバッグ用)" + ) + return parser + + +def _resolve_paths(options: argparse.Namespace) -> None: + """既定パスの補完と存在確認を行う. + + Raises: + FileNotFoundError: 必要なファイル・フォルダが無い場合. + """ + repository_root = Path(__file__).resolve().parent.parent + if options.exe is None: + options.exe = repository_root / DEFAULT_EXE_RELATIVE + if options.work_dir is None: + options.work_dir = repository_root / DEFAULT_WORK_DIR_RELATIVE + options.exe = options.exe.resolve() + options.work_dir = options.work_dir.resolve() + options.matrix = options.matrix.resolve() + options.images_dir = options.images_dir.resolve() + + if not options.exe.is_file(): + raise FileNotFoundError( + f"旧 exe が見つかりません: {options.exe}\n" + " → --exe でパスを指定してください(Release ビルドが必要です)." + ) + if not (options.work_dir / "data.csv").is_file(): + raise FileNotFoundError( + f"旧 exe の作業フォルダに data.csv がありません: {options.work_dir}\n" + " → --work-dir で data.csv と img/ のあるフォルダを指定してください." + ) + if not options.matrix.is_file(): + raise FileNotFoundError(f"変換行列 CSV が見つかりません: {options.matrix}") + if not options.images_dir.is_dir(): + raise FileNotFoundError(f"画像フォルダが見つかりません: {options.images_dir}") + + +def main(argv: list[str] | None = None) -> int: + """エントリポイント.戻り値はプロセスの終了コード.""" + global _verbose # noqa: PLW0603 - デバッグ表示の切り替えだけに使う + options = _build_parser().parse_args(argv) + _verbose = options.verbose + + if sys.platform != "win32": + print("エラー: このスクリプトは Windows 専用です.", file=sys.stderr) + return 1 + try: + _resolve_paths(options) + except FileNotFoundError as error: + print(f"エラー: {error}", file=sys.stderr) + return 1 + + _configure_api() + targets = collect_targets(options.images_dir, options.single) + if not targets: + print("エラー: 採取対象の PNG がありません.", file=sys.stderr) + return 1 + + print(f"# 旧 exe: {options.exe}") + print(f"# 変換行列: {options.matrix}") + print(f"# 出力: {options.out}") + print(f"# 対象: {len(targets)} 枚") + + succeeded: list[str] = [] + failed: list[tuple[str, str]] = [] + for index, target in enumerate(targets, start=1): + print(f"\n[{index}/{len(targets)}] {target.path.name} RGB={target.rgb}") + try: + row = capture_one(target, options) + except CaptureError as error: + print(f" 失敗: {error}") + failed.append((target.path.name, str(error))) + continue + append_row(options.out, row) + succeeded.append(target.path.name) + print( + " L*a*b* = " + f"({row['l_star']}, {row['a_star']}, {row['b_star']})" + f" / 紫 {row['purple']} 暗赤 {row['dark_red']} 紅 {row['crimson']}" + f" 淡紅 {row['pink']} 淡白 {row['pale']} / failed={row['failed']}" + ) + + print(f"\n# 完了: 成功 {len(succeeded)} 枚 / 失敗 {len(failed)} 枚") + for name, reason in failed: + print(f" - {name}: {reason}") + return 0 if not failed else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/read_legacy_values.py b/scripts/read_legacy_values.py new file mode 100644 index 0000000..aabf1da --- /dev/null +++ b/scripts/read_legacy_values.py @@ -0,0 +1,446 @@ +"""旧 exe のメイン画面から診断値を Win32 API で直接読み取る(回帰テスト採取の補助). + +回帰テスト(TEST_01)では旧 exe が表示した L\\*a\\*b\\* と 5 色スコアを転記するが, +旧実装のバグにより **「紫」のスコアが画面上で読めない**. +`tonguediagnosisMFC2Dlg.cpp:683` の `font2.CreateFontA(150, ...)` は +`CreatePointFont(150, ...)`(= 15pt)と違って **高さ 150 ピクセル**のフォントを作るため, +`IDC_PURPLE` のラベルだけが巨大化して枠からはみ出し,値が判読できない. + +ただし静的コントロールの**テキスト自体は正しく設定されている**ので, +`WM_GETTEXT` で文字列を直接吸い出せば正しい値が得られる.本スクリプトはそれを行う. + +依存は Python 標準の `ctypes` のみ(pywin32 等は使わない).**Windows 専用**. + +使い方(詳細は docs/06_TEST/TEST_01_回帰テスト採取手順.md): + +1. 旧 exe を起動し,変換行列と単色 PNG を読み込んで «舌色診断» まで実行しておく +2. 旧 exe を**閉じずに**,リポジトリ直下で次を実行する + +```bash +uv run python scripts/read_legacy_values.py +``` + +採取表(`tests/fixtures/regression/*.csv`)に貼れる形式で 8 値を表示する. + +うまく読めないときのオプション: + +```bash +# 起動中のトップレベルウィンドウのタイトル一覧を出す(ウィンドウ特定に失敗する場合) +uv run python scripts/read_legacy_values.py --list-windows + +# 見つかったウィンドウの全子コントロールの生テキストを出す(パース失敗時のデバッグ用) +uv run python scripts/read_legacy_values.py --raw + +# ウィンドウタイトルの一部を明示指定する(既定は "TIDA") +uv run python scripts/read_legacy_values.py --title "TIDA<診断>" +``` + +注意: 本スクリプトは**読み取り専用**で,旧 exe に対して値の書き換えや操作は行わない. +""" + +from __future__ import annotations + +import argparse +import ctypes +import re +import sys +from ctypes import wintypes +from dataclasses import dataclass, field + +#: 旧 exe のウィンドウタイトルに含まれる文字列(部分一致で探す) +DEFAULT_TITLE_KEYWORD = "TIDA" + +#: 採取表(`rgb_to_lab.csv` / `rgb_to_scores.csv`)に合わせた出力順 +OUTPUT_FIELDS: tuple[str, ...] = ( + "l_star", + "a_star", + "b_star", + "purple", + "dark_red", + "crimson", + "pink", + "pale", +) + +#: 5 色スコアのラベル(旧実装 `m_xPurple = "紫 " + tempPu + "%"` 等). +#: 「紅」は「淡紅」「淡白紅」の一部でもあるため,**長いラベルから先に**照合する +SCORE_LABELS: tuple[tuple[str, str], ...] = ( + ("pale", "淡白紅"), + ("pink", "淡紅"), + ("dark_red", "暗赤"), + ("purple", "紫"), + ("crimson", "紅"), +) + +#: L*a*b* のラベル(旧実装 `m_xOketsu = "L* " + tempO` 等) +LAB_LABELS: tuple[tuple[str, str], ...] = ( + ("l_star", "L*"), + ("a_star", "a*"), + ("b_star", "b*"), +) + +#: 値部分(`%3.0lf` は右詰めなので前後に空白が入りうる.負値もありうる) +NUMBER_PATTERN = r"[+-]?\d+" + +# --- Win32 API(ctypes.必要な分だけ宣言する)--- + +WM_GETTEXT = 0x000D +WM_GETTEXTLENGTH = 0x000E +SMTO_ABORTIFHUNG = 0x0002 +#: 応答の無いウィンドウで固まらないための待ち時間(ミリ秒) +SEND_MESSAGE_TIMEOUT_MS = 2000 +#: 1 コントロールから読み取る最大文字数(静的テキストなので十分に大きい) +MAX_TEXT_LENGTH = 1024 + +if sys.platform == "win32": + _user32 = ctypes.WinDLL("user32", use_last_error=True) +else: # pragma: no cover - Windows 以外では起動時に弾く + _user32 = None + +WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + + +def configure_api() -> None: + """使用する API の引数・戻り値の型を設定する(64bit で値が壊れないようにする).""" + _user32.EnumWindows.argtypes = [WNDENUMPROC, wintypes.LPARAM] + _user32.EnumWindows.restype = wintypes.BOOL + _user32.EnumChildWindows.argtypes = [wintypes.HWND, WNDENUMPROC, wintypes.LPARAM] + _user32.EnumChildWindows.restype = wintypes.BOOL + _user32.GetWindowTextLengthW.argtypes = [wintypes.HWND] + _user32.GetWindowTextLengthW.restype = ctypes.c_int + _user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] + _user32.GetWindowTextW.restype = ctypes.c_int + _user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] + _user32.GetClassNameW.restype = ctypes.c_int + _user32.IsWindowVisible.argtypes = [wintypes.HWND] + _user32.IsWindowVisible.restype = wintypes.BOOL + _user32.SendMessageTimeoutW.argtypes = [ + wintypes.HWND, + wintypes.UINT, + wintypes.WPARAM, + wintypes.LPARAM, + wintypes.UINT, + wintypes.UINT, + ctypes.POINTER(ctypes.c_size_t), + ] + _user32.SendMessageTimeoutW.restype = wintypes.LPARAM + + +@dataclass +class Control: + """読み取った子コントロール 1 個分の情報. + + Attributes: + handle: ウィンドウハンドル(整数). + class_name: ウィンドウクラス名(Static / Edit 等). + text: 取得したテキスト. + """ + + handle: int + class_name: str + text: str + + +@dataclass +class ParseResult: + """パース結果. + + Attributes: + values: 読み取れた項目(`OUTPUT_FIELDS` のキー → 整数値). + unmatched_numbers: どのラベルにも紐づかなかった数値だけのテキスト. + """ + + values: dict[str, int] = field(default_factory=dict) + unmatched_numbers: list[str] = field(default_factory=list) + + @property + def missing(self) -> list[str]: + """読み取れなかった項目の一覧.""" + return [name for name in OUTPUT_FIELDS if name not in self.values] + + +# --- ウィンドウ列挙・テキスト取得 --- + + +def window_text(handle: int) -> str: + """ウィンドウのタイトル(キャプション)を返す.""" + length = _user32.GetWindowTextLengthW(handle) + if length <= 0: + return "" + buffer = ctypes.create_unicode_buffer(length + 1) + _user32.GetWindowTextW(handle, buffer, length + 1) + return buffer.value + + +def _class_name(handle: int) -> str: + """ウィンドウクラス名を返す.""" + buffer = ctypes.create_unicode_buffer(256) + _user32.GetClassNameW(handle, buffer, 256) + return buffer.value + + +def control_text(handle: int) -> str: + """`WM_GETTEXT` で子コントロールのテキストを読む. + + 旧 exe は ANSI(CP932)ビルドだが,`SendMessageTimeoutW`(W 版)を使えば + Windows 側が UTF-16 へ変換して返すため文字化けしない. + 応答が無いウィンドウで固まらないよう,タイムアウト付きで送る. + """ + result = ctypes.c_size_t(0) + length_ok = _user32.SendMessageTimeoutW( + handle, + WM_GETTEXTLENGTH, + 0, + 0, + SMTO_ABORTIFHUNG, + SEND_MESSAGE_TIMEOUT_MS, + ctypes.byref(result), + ) + if not length_ok: + return "" + length = min(int(result.value), MAX_TEXT_LENGTH) + if length <= 0: + return "" + + buffer = ctypes.create_unicode_buffer(length + 1) + text_ok = _user32.SendMessageTimeoutW( + handle, + WM_GETTEXT, + length + 1, + ctypes.cast(buffer, ctypes.c_void_p).value or 0, + SMTO_ABORTIFHUNG, + SEND_MESSAGE_TIMEOUT_MS, + ctypes.byref(result), + ) + if not text_ok: + return "" + return buffer.value + + +def list_top_level_windows(*, visible_only: bool = True) -> list[tuple[int, str]]: + """トップレベルウィンドウの (ハンドル, タイトル) 一覧を返す.""" + windows: list[tuple[int, str]] = [] + + def callback(handle: int, _param: int) -> bool: + if visible_only and not _user32.IsWindowVisible(handle): + return True + title = window_text(handle) + if title: + windows.append((handle, title)) + return True + + _user32.EnumWindows(WNDENUMPROC(callback), 0) + return windows + + +def find_window(keyword: str) -> tuple[int, str] | None: + """タイトルに `keyword` を含む最初のウィンドウを返す(部分一致・大小文字無視).""" + needle = keyword.casefold() + for handle, title in list_top_level_windows(): + if needle in title.casefold(): + return handle, title + return None + + +def read_controls(handle: int) -> list[Control]: + """指定ウィンドウの全子コントロール(孫以下も含む)のテキストを読む.""" + controls: list[Control] = [] + + def callback(child: int, _param: int) -> bool: + controls.append(Control(child, _class_name(child), control_text(child))) + return True + + _user32.EnumChildWindows(handle, WNDENUMPROC(callback), 0) + return controls + + +# --- パース --- + + +def parse_values(texts: list[str]) -> ParseResult: + """コントロールのテキスト一覧から 8 値を取り出す. + + 読めた項目だけを `values` に入れ,読めなかった項目は `missing` で分かるようにする + (取りこぼしがあっても分かる範囲は出す方針). + + Args: + texts: 子コントロールから読んだテキストの一覧. + + Returns: + パース結果. + """ + result = ParseResult() + for raw in texts: + text = raw.replace("\u3000", " ").strip() + if not text: + continue + if _match_labeled(text, SCORE_LABELS, result) or _match_labeled(text, LAB_LABELS, result): + continue + # ラベル無しで数値だけのコントロール(旧実装は `m_purpleStr` 等も保持している) + if re.fullmatch(rf"\s*{NUMBER_PATTERN}\s*%?\s*", text): + result.unmatched_numbers.append(text.strip()) + + _apply_purple_fallback(result) + return result + + +def _match_labeled(text: str, labels: tuple[tuple[str, str], ...], result: ParseResult) -> bool: + """`"紫 42%"` や `"L* -16"` の形からラベルと値を取り出して `result` に入れる.""" + for name, label in labels: + # ラベルは文字列の先頭に来る(「紅」が「淡紅」の一部に一致しないよう前方一致で見る) + pattern = rf"^{re.escape(label)}\s*({NUMBER_PATTERN})\s*%?$" + matched = re.match(pattern, text) + if matched is None: + continue + # 同じラベルが複数あった場合は最初に読めた値を優先する + result.values.setdefault(name, int(matched.group(1))) + return True + return False + + +def _apply_purple_fallback(result: ParseResult) -> None: + """紫がラベル付きで読めなかった場合に,数値だけのコントロールで補う. + + 巨大フォントのバグで «見た目» は壊れているがテキストは正しいはずなので, + 通常はラベル付きで読める.ここは «ラベルごと壊れていた» 場合の保険であり, + 候補が 1 つに絞れるときだけ採用する(誤った値を黙って使わないため). + """ + if "purple" in result.values: + return + candidates = [text for text in result.unmatched_numbers if text.strip("% ").lstrip("+-")] + if len(candidates) != 1: + return + number = re.search(NUMBER_PATTERN, candidates[0]) + if number is None: + return + result.values["purple"] = int(number.group(0)) + result.unmatched_numbers.remove(candidates[0]) + print( + "警告: 「紫」をラベル無しの数値コントロールから推定しました" + f"(値: {result.values['purple']}).--raw で妥当性を確認してください.", + file=sys.stderr, + ) + + +# --- 出力 --- + + +def format_csv_row(result: ParseResult) -> str: + """採取表に貼れる 1 行を作る(読めなかった項目は空欄にする).""" + return ",".join(str(result.values.get(name, "")) for name in OUTPUT_FIELDS) + + +def print_result(result: ParseResult) -> None: + """パース結果を人間が転記しやすい形で表示する.""" + print("# 読み取り結果") + for name in OUTPUT_FIELDS: + value = result.values.get(name) + print(f" {name:8s} = {'(読めませんでした)' if value is None else value}") + + print() + print(",".join(OUTPUT_FIELDS)) + print(format_csv_row(result)) + + if result.missing: + print() + print( + f"警告: 次の項目を読み取れませんでした: {', '.join(result.missing)}", + file=sys.stderr, + ) + print( + " → 旧 exe で «舌色診断» を実行済みか確認し,--raw で生テキストを確認してください.", + file=sys.stderr, + ) + if result.unmatched_numbers: + print( + f"参考: ラベル無しの数値コントロール: {result.unmatched_numbers}", + file=sys.stderr, + ) + + +def print_raw(controls: list[Control]) -> None: + """全子コントロールの生テキストを表示する(パース失敗時のデバッグ用).""" + print(f"# 子コントロール {len(controls)} 個") + for control in controls: + text = control.text.replace("\r", "\\r").replace("\n", "\\n") + print(f" hwnd=0x{control.handle:08X} class={control.class_name:<16} text={text!r}") + + +def _build_parser() -> argparse.ArgumentParser: + """コマンドライン引数のパーサを作る.""" + parser = argparse.ArgumentParser( + description="旧 exe のメイン画面から診断値(L*a*b* と 5 色スコア)を読み取る.", + ) + parser.add_argument( + "--title", + default=DEFAULT_TITLE_KEYWORD, + help=f"旧 exe のウィンドウタイトルに含まれる文字列(既定: {DEFAULT_TITLE_KEYWORD})", + ) + parser.add_argument( + "--raw", + action="store_true", + help="全子コントロールの生テキストも表示する(パース失敗時のデバッグ用)", + ) + parser.add_argument( + "--list-windows", + action="store_true", + help="トップレベルウィンドウのタイトル一覧を表示して終了する", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """エントリポイント.戻り値はプロセスの終了コード.""" + arguments = _build_parser().parse_args(argv) + + if sys.platform != "win32": + print( + "エラー: このスクリプトは Windows 専用です(Win32 API を使用します).", + file=sys.stderr, + ) + return 1 + configure_api() + + if arguments.list_windows: + print("# トップレベルウィンドウ一覧") + for handle, title in list_top_level_windows(): + print(f" hwnd=0x{handle:08X} title={title!r}") + return 0 + + found = find_window(arguments.title) + if found is None: + print( + f"エラー: タイトルに {arguments.title!r} を含むウィンドウが見つかりません.", + file=sys.stderr, + ) + print( + " → 旧 exe を起動したまま実行してください." + "ウィンドウ名が違う場合は --title で指定するか," + "--list-windows で一覧を確認してください.", + file=sys.stderr, + ) + return 1 + + handle, title = found + print(f"# 対象ウィンドウ: {title!r} (hwnd=0x{handle:08X})") + controls = read_controls(handle) + if not controls: + print( + "エラー: 子コントロールを 1 つも取得できませんでした." + "別のウィンドウを掴んでいる可能性があります(--list-windows で確認).", + file=sys.stderr, + ) + return 1 + + if arguments.raw: + print_raw(controls) + print() + + result = parse_values([control.text for control in controls]) + print_result(result) + # 8 値すべて読めたときだけ 0 を返す(採取の取りこぼしに気付けるようにする) + return 0 if not result.missing else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/regression/matrices/Conv_RGB-XYZold.csv b/tests/fixtures/regression/matrices/Conv_RGB-XYZold.csv new file mode 100644 index 0000000..8afbe63 --- /dev/null +++ b/tests/fixtures/regression/matrices/Conv_RGB-XYZold.csv @@ -0,0 +1,17 @@ +0.3568006,0.1020508,-0.3463658 +-0.6486439,0.2657089,1.9863226 +0.8440233,0.0691743,-1.4296536 +0.0060381,0.0033695,0.0195682 +-0.0090780,-0.0042553,0.0022806 +0.0145155,0.0081394,0.0069290 +-0.0006202,-0.0008518,-0.0050802 +-0.0021340,-0.0045963,-0.0419769 +-0.0107356,-0.0028151,0.0193294 +0.0000319,0.0000083,0.0000565 +-0.0000281,0.0000012,0.0000193 +0.0000563,0.0000157,-0.0000286 +-0.0000607,0.0000158,0.0004896 +0.0000625,0.0000437,0.0000268 +0.0000448,-0.0000182,-0.0003206 +-0.0001017,-0.0000638,-0.0002438 +-12.1025532,-6.7535913,0.1486005 diff --git a/tests/fixtures/regression/rgb_to_lab.csv b/tests/fixtures/regression/rgb_to_lab.csv new file mode 100644 index 0000000..475f329 --- /dev/null +++ b/tests/fixtures/regression/rgb_to_lab.csv @@ -0,0 +1,15 @@ +# 旧 exe(TIDA ver 2.2)から scripts/auto_capture.py で自動採取(2026-08-10) +# 変換行列: matrices/Conv_RGB-XYZold.csv +matrix,r,g,b,l_star,a_star,b_star,note +Conv_RGB-XYZold.csv,0,0,0,-16,0,-25,境界値: 黒 +Conv_RGB-XYZold.csv,0,0,255,-16,0,-448,域外: 純青 +Conv_RGB-XYZold.csv,115,62,68,47,41,-17,暗い舌色 +Conv_RGB-XYZold.csv,140,85,85,56,29,-16,やや暗い舌色 +Conv_RGB-XYZold.csv,150,110,130,65,25,-8,青み寄り +Conv_RGB-XYZold.csv,156,102,98,62,22,-12,基準色 +Conv_RGB-XYZold.csv,170,92,112,59,49,-32,赤紫寄り +Conv_RGB-XYZold.csv,180,120,115,67,19,-10,やや明るい舌色 +Conv_RGB-XYZold.csv,196,74,74,49,73,-31,鮮やかな紅寄り +Conv_RGB-XYZold.csv,205,145,140,74,14,0,淡い舌色 +Conv_RGB-XYZold.csv,255,0,0,-16,361,0,域外: 純赤 +Conv_RGB-XYZold.csv,255,255,255,93,-9,-34,境界値: 白 diff --git a/tests/fixtures/regression/rgb_to_scores.csv b/tests/fixtures/regression/rgb_to_scores.csv new file mode 100644 index 0000000..bdbafa7 --- /dev/null +++ b/tests/fixtures/regression/rgb_to_scores.csv @@ -0,0 +1,15 @@ +# 旧 exe(TIDA ver 2.2)から scripts/auto_capture.py で自動採取(2026-08-10) +# 変換行列: matrices/Conv_RGB-XYZold.csv +matrix,r,g,b,purple,dark_red,crimson,pink,pale,failed,note +Conv_RGB-XYZold.csv,0,0,0,42,0,5,11,3,yes,境界値: 黒 +Conv_RGB-XYZold.csv,0,0,255,42,0,5,11,3,yes,域外: 純青 +Conv_RGB-XYZold.csv,115,62,68,42,0,5,11,3,yes,暗い舌色 +Conv_RGB-XYZold.csv,140,85,85,37,5,3,3,16,no,やや暗い舌色 +Conv_RGB-XYZold.csv,150,110,130,42,24,8,8,16,no,青み寄り +Conv_RGB-XYZold.csv,156,102,98,45,11,3,11,24,no,基準色 +Conv_RGB-XYZold.csv,170,92,112,42,0,5,11,3,yes,赤紫寄り +Conv_RGB-XYZold.csv,180,120,115,58,18,0,5,18,no,やや明るい舌色 +Conv_RGB-XYZold.csv,196,74,74,42,0,5,11,3,yes,鮮やかな紅寄り +Conv_RGB-XYZold.csv,205,145,140,79,13,5,0,5,no,淡い舌色 +Conv_RGB-XYZold.csv,255,0,0,42,0,5,11,3,yes,域外: 純赤 +Conv_RGB-XYZold.csv,255,255,255,42,0,5,11,3,yes,境界値: 白