Newer
Older
TIASjudge / tests / test_regression.py
"""旧実装 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})"
    )