"""旧実装 exe との回帰テスト(PLAN_02 ステップ 5).
`tests/fixtures/regression/` に置かれた «旧 exe で採取した入出力ペア» を読み,
新実装のパイプライン(`color.rgb_to_lab` → `svm.diagnose`)の出力と照合する.
フィクスチャ CSV が未採取(ファイルが無い・データ行が 0)の間はテストを skip する.
CSV を置いた瞬間に有効化される.形式は `tests/fixtures/regression/README.md`,
採取手順は `docs/06_TEST/TEST_01_回帰テスト採取手順.md` を参照.
照合の許容誤差(README と同じ根拠):
- L*a*b*: 旧 exe は `Format("%3.0lf", ...)` で整数しか表示しないため,絶対誤差 0.51 以内
- 5 色スコア(%): 表示値どうしの整数比較(`k / 38 × 100` は x.5 にならないため厳密比較で安全)
- 診断失敗の有無: 完全一致
"""
from __future__ import annotations
import csv
from collections.abc import Callable, Iterable
from pathlib import Path
import numpy as np
import pytest
from numpy.typing import NDArray
from tiasjudge.core.color import rgb_to_lab
from tiasjudge.core.csv_io import TrainingData, load_training_data, load_transform_matrix
from tiasjudge.core.svm import COLOR_KEYS, diagnose
REPO_ROOT = Path(__file__).resolve().parents[1]
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "regression"
MATRIX_DIR = FIXTURE_DIR / "matrices"
FORMAT_DOC = FIXTURE_DIR / "README.md"
CAPTURE_DOC = "docs/06_TEST/TEST_01_回帰テスト採取手順.md"
RGB_TO_LAB_CSV = FIXTURE_DIR / "rgb_to_lab.csv"
RGB_TO_SCORES_CSV = FIXTURE_DIR / "rgb_to_scores.csv"
LAB_TO_SCORES_CSV = FIXTURE_DIR / "lab_to_scores.csv"
#: L*a*b* の許容誤差(旧 exe の表示丸め幅 0.5 + 微小マージン)
LAB_DISPLAY_TOLERANCE = 0.51
#: 真偽値として受け付ける表記(`failed` 列)
_TRUE_WORDS = frozenset({"yes", "y", "true", "1", "○"})
_FALSE_WORDS = frozenset({"", "no", "n", "false", "0", "-", "×"})
# --- フィクスチャ読込 (Fixture Loading) ---
def _read_fixture_rows(path: Path) -> list[dict[str, str]]:
"""回帰フィクスチャ CSV を読む(未採取なら空リスト).
`#` で始まる行と空行は無視する.値は前後の空白を除いて返す.
"""
if not path.exists():
return []
lines = [
line
for line in path.read_text(encoding="utf-8-sig").splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
reader = csv.DictReader(lines)
return [
{(key or "").strip(): (value or "").strip() for key, value in row.items()} for row in reader
]
def _skip_reason(path: Path) -> str:
"""未採取時の skip 理由(採取手順への参照を含む)を組み立てる."""
return (
f"回帰フィクスチャ未採取: {path.relative_to(REPO_ROOT).as_posix()} が無い"
f"(または データ行が 0).旧 exe での採取手順は {CAPTURE_DOC},"
f"CSV の形式は {FORMAT_DOC.relative_to(REPO_ROOT).as_posix()} を参照"
)
def _cases(path: Path, id_columns: Iterable[str]) -> list[object]:
"""CSV の各行を parametrize 用のパラメータに変換する.
未採取の場合は «skip マーク付きの 1 件» を返し,理由に採取手順書を示す.
"""
rows = _read_fixture_rows(path)
if not rows:
return [pytest.param(None, marks=pytest.mark.skip(reason=_skip_reason(path)), id="未採取")]
columns = tuple(id_columns)
return [
pytest.param(row, id="-".join(row.get(column, "?") for column in columns) + f"#{index}")
for index, row in enumerate(rows, start=1)
]
# --- 値の取り出し (Value Access) ---
def _value(row: dict[str, str], column: str, path: Path) -> str:
"""必須列の値を取り出す(列が無ければ形式エラーとして失敗させる)."""
if column not in row:
pytest.fail(
f"{path.name} に列 `{column}` がありません(列: {sorted(row)})."
f"形式は {FORMAT_DOC.name} を参照"
)
return row[column]
def _number(row: dict[str, str], column: str, path: Path) -> float:
"""必須列を実数として取り出す."""
text = _value(row, column, path)
try:
return float(text)
except ValueError:
pytest.fail(f"{path.name} の列 `{column}` が数値ではありません: {text!r}")
def _percent(row: dict[str, str], column: str, path: Path) -> int:
"""スコア列(%)を整数として取り出す."""
value = _number(row, column, path)
if value != int(value):
pytest.fail(
f"{path.name} の列 `{column}` は整数(旧 exe の表示値)である必要があります: {value}"
)
return int(value)
def _flag(row: dict[str, str], column: str, path: Path) -> bool:
"""`failed` 列を真偽値として取り出す(列が無い・空欄なら False)."""
text = row.get(column, "").lower()
if text in _TRUE_WORDS:
return True
if text in _FALSE_WORDS:
return False
pytest.fail(f"{path.name} の列 `{column}` は yes / no で記入してください: {text!r}")
def _expected_percentages(row: dict[str, str], path: Path) -> dict[str, int]:
"""5 色スコアの期待値(%)をまとめて取り出す."""
return {key: _percent(row, key, path) for key in COLOR_KEYS}
# --- pytest フィクスチャ (Pytest Fixtures) ---
@pytest.fixture(scope="session")
def training_data(data_csv_path: Path) -> TrainingData:
"""同梱の学習データ(`data/data.csv`)を読み込む."""
return load_training_data(data_csv_path)
@pytest.fixture(scope="session")
def transform_matrix() -> Callable[[str], NDArray[np.float64]]:
"""`matrices/` の変換行列 CSV を名前で読み込む関数を返す(読込結果を再利用する)."""
cache: dict[str, NDArray[np.float64]] = {}
def load(name: str) -> NDArray[np.float64]:
if name not in cache:
path = MATRIX_DIR / name
if not path.exists():
pytest.fail(
f"変換行列 CSV がありません: {path}."
f"採取に使った変換行列を {MATRIX_DIR.name}/ に置いてください"
f"({FORMAT_DOC.name} 参照)"
)
cache[name] = load_transform_matrix(path)
return cache[name]
return load
# --- 器の健全性 (Scaffold Sanity) ---
def test_fixture_scaffold_exists() -> None:
"""フィクスチャの器(ディレクトリ・形式定義・変換行列置き場)が揃っている."""
assert FIXTURE_DIR.is_dir(), f"回帰フィクスチャのディレクトリがありません: {FIXTURE_DIR}"
assert FORMAT_DOC.is_file(), f"フィクスチャ形式定義がありません: {FORMAT_DOC}"
assert MATRIX_DIR.is_dir(), f"変換行列の置き場がありません: {MATRIX_DIR}"
# --- ① RGB → L*a*b* ---
@pytest.mark.parametrize("row", _cases(RGB_TO_LAB_CSV, ("r", "g", "b")))
def test_rgb_to_lab_matches_legacy(
row: dict[str, str], transform_matrix: Callable[[str], NDArray[np.float64]]
) -> None:
"""旧 exe が表示した L*a*b* と新実装の色変換が一致する(SPEC_02 (1)(2))."""
path = RGB_TO_LAB_CSV
coefficients = transform_matrix(_value(row, "matrix", path))
rgb = (_number(row, "r", path), _number(row, "g", path), _number(row, "b", path))
lab = rgb_to_lab(rgb, coefficients)
actual = {"l_star": lab.l_star, "a_star": lab.a_star, "b_star": lab.b_star}
for column, value in actual.items():
expected = _number(row, column, path)
assert abs(value - expected) <= LAB_DISPLAY_TOLERANCE, (
f"RGB={rgb} の {column} が旧実装と一致しません: "
f"旧 exe 表示={expected}・新実装={value}(許容 {LAB_DISPLAY_TOLERANCE})"
)
# --- ② RGB → 5 色スコア(パイプライン全体)---
@pytest.mark.parametrize("row", _cases(RGB_TO_SCORES_CSV, ("r", "g", "b")))
def test_pipeline_scores_match_legacy(
row: dict[str, str],
transform_matrix: Callable[[str], NDArray[np.float64]],
training_data: TrainingData,
) -> None:
"""RGB → Lab → SVM のパイプライン出力が旧 exe の 5 色スコアと一致する(SPEC_02)."""
path = RGB_TO_SCORES_CSV
coefficients = transform_matrix(_value(row, "matrix", path))
rgb = (_number(row, "r", path), _number(row, "g", path), _number(row, "b", path))
result = diagnose(rgb_to_lab(rgb, coefficients), training_data)
expected = _expected_percentages(row, path)
assert result.percentages == expected, (
f"RGB={rgb} の 5 色スコアが旧実装と一致しません: "
f"旧 exe 表示={expected}・新実装={result.percentages}(Lab={result.lab})"
)
assert result.failed == _flag(row, "failed", path), (
f"RGB={rgb} の診断失敗判定が旧実装と一致しません: "
f"旧 exe={_flag(row, 'failed', path)}・新実装={result.failed}"
f"(理由: {result.failure_reason})"
)
# --- ③ L*a*b* → 5 色スコア(任意)---
@pytest.mark.parametrize("row", _cases(LAB_TO_SCORES_CSV, ("l_star", "a_star", "b_star")))
def test_lab_to_scores_match_legacy(row: dict[str, str], training_data: TrainingData) -> None:
"""厳密な L*a*b* を入力にしたときの 5 色スコアが旧実装と一致する(SPEC_02 (3))."""
path = LAB_TO_SCORES_CSV
lab = (
_number(row, "l_star", path),
_number(row, "a_star", path),
_number(row, "b_star", path),
)
result = diagnose(lab, training_data)
expected = _expected_percentages(row, path)
assert result.percentages == expected, (
f"Lab={lab} の 5 色スコアが旧実装と一致しません: "
f"旧 exe 表示={expected}・新実装={result.percentages}"
)
assert result.failed == _flag(row, "failed", path), (
f"Lab={lab} の診断失敗判定が旧実装と一致しません: "
f"旧 exe={_flag(row, 'failed', path)}・新実装={result.failed}"
f"(理由: {result.failure_reason})"
)